xref: /aosp_15_r20/external/perfetto/ui/src/frontend/viewer_page.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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 {hex} from 'color-convert';
16import m from 'mithril';
17import {removeFalsyValues} from '../base/array_utils';
18import {canvasClip, canvasSave} from '../base/canvas_utils';
19import {findRef, toHTMLElement} from '../base/dom_utils';
20import {Size2D, VerticalBounds} from '../base/geom';
21import {assertExists} from '../base/logging';
22import {clamp} from '../base/math_utils';
23import {Time, TimeSpan} from '../base/time';
24import {TimeScale} from '../base/time_scale';
25import {featureFlags} from '../core/feature_flags';
26import {raf} from '../core/raf_scheduler';
27import {TrackNode} from '../public/workspace';
28import {TRACK_BORDER_COLOR, TRACK_SHELL_WIDTH} from './css_constants';
29import {renderFlows} from './flow_events_renderer';
30import {generateTicks, getMaxMajorTicks, TickType} from './gridline_helper';
31import {NotesPanel} from './notes_panel';
32import {OverviewTimelinePanel} from './overview_timeline_panel';
33import {PanAndZoomHandler} from './pan_and_zoom_handler';
34import {
35  PanelContainer,
36  PanelOrGroup,
37  RenderedPanelInfo,
38} from './panel_container';
39import {TabPanel} from './tab_panel';
40import {TickmarkPanel} from './tickmark_panel';
41import {TimeAxisPanel} from './time_axis_panel';
42import {TimeSelectionPanel} from './time_selection_panel';
43import {TrackPanel} from './track_panel';
44import {drawVerticalLineAtTime} from './vertical_line_helper';
45import {TraceImpl} from '../core/trace_impl';
46import {PageWithTraceImplAttrs} from '../core/page_manager';
47import {AppImpl} from '../core/app_impl';
48
49const OVERVIEW_PANEL_FLAG = featureFlags.register({
50  id: 'overviewVisible',
51  name: 'Overview Panel',
52  description: 'Show the panel providing an overview of the trace',
53  defaultValue: true,
54});
55
56// Checks if the mousePos is within 3px of the start or end of the
57// current selected time range.
58function onTimeRangeBoundary(
59  trace: TraceImpl,
60  timescale: TimeScale,
61  mousePos: number,
62): 'START' | 'END' | null {
63  const selection = trace.selection.selection;
64  if (selection.kind === 'area') {
65    // If frontend selectedArea exists then we are in the process of editing the
66    // time range and need to use that value instead.
67    const area = trace.timeline.selectedArea
68      ? trace.timeline.selectedArea
69      : selection;
70    const start = timescale.timeToPx(area.start);
71    const end = timescale.timeToPx(area.end);
72    const startDrag = mousePos - TRACK_SHELL_WIDTH;
73    const startDistance = Math.abs(start - startDrag);
74    const endDistance = Math.abs(end - startDrag);
75    const range = 3 * window.devicePixelRatio;
76    // We might be within 3px of both boundaries but we should choose
77    // the closest one.
78    if (startDistance < range && startDistance <= endDistance) return 'START';
79    if (endDistance < range && endDistance <= startDistance) return 'END';
80  }
81  return null;
82}
83
84interface SelectedContainer {
85  readonly containerClass: string;
86  readonly dragStartAbsY: number;
87  readonly dragEndAbsY: number;
88}
89
90/**
91 * Top-most level component for the viewer page. Holds tracks, brush timeline,
92 * panels, and everything else that's part of the main trace viewer page.
93 */
94export class ViewerPage implements m.ClassComponent<PageWithTraceImplAttrs> {
95  private zoomContent?: PanAndZoomHandler;
96  // Used to prevent global deselection if a pan/drag select occurred.
97  private keepCurrentSelection = false;
98
99  private overviewTimelinePanel: OverviewTimelinePanel;
100  private timeAxisPanel: TimeAxisPanel;
101  private timeSelectionPanel: TimeSelectionPanel;
102  private notesPanel: NotesPanel;
103  private tickmarkPanel: TickmarkPanel;
104  private timelineWidthPx?: number;
105  private selectedContainer?: SelectedContainer;
106  private showPanningHint = false;
107
108  private readonly PAN_ZOOM_CONTENT_REF = 'pan-and-zoom-content';
109
110  constructor(vnode: m.CVnode<PageWithTraceImplAttrs>) {
111    this.notesPanel = new NotesPanel(vnode.attrs.trace);
112    this.timeAxisPanel = new TimeAxisPanel(vnode.attrs.trace);
113    this.timeSelectionPanel = new TimeSelectionPanel(vnode.attrs.trace);
114    this.tickmarkPanel = new TickmarkPanel(vnode.attrs.trace);
115    this.overviewTimelinePanel = new OverviewTimelinePanel(vnode.attrs.trace);
116    this.notesPanel = new NotesPanel(vnode.attrs.trace);
117    this.timeSelectionPanel = new TimeSelectionPanel(vnode.attrs.trace);
118  }
119
120  oncreate({dom, attrs}: m.CVnodeDOM<PageWithTraceImplAttrs>) {
121    const panZoomElRaw = findRef(dom, this.PAN_ZOOM_CONTENT_REF);
122    const panZoomEl = toHTMLElement(assertExists(panZoomElRaw));
123
124    const {top: panTop} = panZoomEl.getBoundingClientRect();
125    this.zoomContent = new PanAndZoomHandler({
126      element: panZoomEl,
127      onPanned: (pannedPx: number) => {
128        const timeline = attrs.trace.timeline;
129
130        if (this.timelineWidthPx === undefined) return;
131
132        this.keepCurrentSelection = true;
133        const timescale = new TimeScale(timeline.visibleWindow, {
134          left: 0,
135          right: this.timelineWidthPx,
136        });
137        const tDelta = timescale.pxToDuration(pannedPx);
138        timeline.panVisibleWindow(tDelta);
139      },
140      onZoomed: (zoomedPositionPx: number, zoomRatio: number) => {
141        const timeline = attrs.trace.timeline;
142        // TODO(hjd): Avoid hardcoding TRACK_SHELL_WIDTH.
143        // TODO(hjd): Improve support for zooming in overview timeline.
144        const zoomPx = zoomedPositionPx - TRACK_SHELL_WIDTH;
145        const rect = dom.getBoundingClientRect();
146        const centerPoint = zoomPx / (rect.width - TRACK_SHELL_WIDTH);
147        timeline.zoomVisibleWindow(1 - zoomRatio, centerPoint);
148        raf.scheduleCanvasRedraw();
149      },
150      editSelection: (currentPx: number) => {
151        if (this.timelineWidthPx === undefined) return false;
152        const timescale = new TimeScale(attrs.trace.timeline.visibleWindow, {
153          left: 0,
154          right: this.timelineWidthPx,
155        });
156        return onTimeRangeBoundary(attrs.trace, timescale, currentPx) !== null;
157      },
158      onSelection: (
159        dragStartX: number,
160        dragStartY: number,
161        prevX: number,
162        currentX: number,
163        currentY: number,
164        editing: boolean,
165      ) => {
166        const traceTime = attrs.trace.traceInfo;
167        const timeline = attrs.trace.timeline;
168
169        if (this.timelineWidthPx === undefined) return;
170
171        // TODO(stevegolton): Don't get the windowSpan from globals, get it from
172        // here!
173        const {visibleWindow} = timeline;
174        const timespan = visibleWindow.toTimeSpan();
175        this.keepCurrentSelection = true;
176
177        const timescale = new TimeScale(timeline.visibleWindow, {
178          left: 0,
179          right: this.timelineWidthPx,
180        });
181
182        if (editing) {
183          const selection = attrs.trace.selection.selection;
184          if (selection.kind === 'area') {
185            const area = attrs.trace.timeline.selectedArea
186              ? attrs.trace.timeline.selectedArea
187              : selection;
188            let newTime = timescale
189              .pxToHpTime(currentX - TRACK_SHELL_WIDTH)
190              .toTime();
191            // Have to check again for when one boundary crosses over the other.
192            const curBoundary = onTimeRangeBoundary(
193              attrs.trace,
194              timescale,
195              prevX,
196            );
197            if (curBoundary == null) return;
198            const keepTime = curBoundary === 'START' ? area.end : area.start;
199            // Don't drag selection outside of current screen.
200            if (newTime < keepTime) {
201              newTime = Time.max(newTime, timespan.start);
202            } else {
203              newTime = Time.min(newTime, timespan.end);
204            }
205            // When editing the time range we always use the saved tracks,
206            // since these will not change.
207            timeline.selectArea(
208              Time.max(Time.min(keepTime, newTime), traceTime.start),
209              Time.min(Time.max(keepTime, newTime), traceTime.end),
210              selection.trackUris,
211            );
212          }
213        } else {
214          let startPx = Math.min(dragStartX, currentX) - TRACK_SHELL_WIDTH;
215          let endPx = Math.max(dragStartX, currentX) - TRACK_SHELL_WIDTH;
216          if (startPx < 0 && endPx < 0) return;
217          startPx = clamp(startPx, 0, this.timelineWidthPx);
218          endPx = clamp(endPx, 0, this.timelineWidthPx);
219          timeline.selectArea(
220            timescale.pxToHpTime(startPx).toTime('floor'),
221            timescale.pxToHpTime(endPx).toTime('ceil'),
222          );
223
224          const absStartY = dragStartY + panTop;
225          const absCurrentY = currentY + panTop;
226          if (this.selectedContainer === undefined) {
227            for (const c of dom.querySelectorAll('.pf-panel-container')) {
228              const {top, bottom} = c.getBoundingClientRect();
229              if (top <= absStartY && absCurrentY <= bottom) {
230                const stack = assertExists(c.querySelector('.pf-panel-stack'));
231                const stackTop = stack.getBoundingClientRect().top;
232                this.selectedContainer = {
233                  containerClass: Array.from(c.classList).filter(
234                    (x) => x !== 'pf-panel-container',
235                  )[0],
236                  dragStartAbsY: -stackTop + absStartY,
237                  dragEndAbsY: -stackTop + absCurrentY,
238                };
239                break;
240              }
241            }
242          } else {
243            const c = assertExists(
244              dom.querySelector(`.${this.selectedContainer.containerClass}`),
245            );
246            const {top, bottom} = c.getBoundingClientRect();
247            const boundedCurrentY = Math.min(
248              Math.max(top, absCurrentY),
249              bottom,
250            );
251            const stack = assertExists(c.querySelector('.pf-panel-stack'));
252            const stackTop = stack.getBoundingClientRect().top;
253            this.selectedContainer = {
254              ...this.selectedContainer,
255              dragEndAbsY: -stackTop + boundedCurrentY,
256            };
257          }
258          this.showPanningHint = true;
259        }
260        raf.scheduleCanvasRedraw();
261      },
262      endSelection: (edit: boolean) => {
263        this.selectedContainer = undefined;
264        const area = attrs.trace.timeline.selectedArea;
265        // If we are editing we need to pass the current id through to ensure
266        // the marked area with that id is also updated.
267        if (edit) {
268          const selection = attrs.trace.selection.selection;
269          if (selection.kind === 'area' && area) {
270            attrs.trace.selection.selectArea({...area});
271          }
272        } else if (area) {
273          attrs.trace.selection.selectArea({...area});
274        }
275        // Now the selection has ended we stored the final selected area in the
276        // global state and can remove the in progress selection from the
277        // timeline.
278        attrs.trace.timeline.deselectArea();
279        // Full redraw to color track shell.
280        raf.scheduleFullRedraw();
281      },
282    });
283  }
284
285  onremove() {
286    if (this.zoomContent) this.zoomContent[Symbol.dispose]();
287  }
288
289  view({attrs}: m.CVnode<PageWithTraceImplAttrs>) {
290    const scrollingPanels = renderToplevelPanels(attrs.trace);
291
292    const result = m(
293      '.page.viewer-page',
294      m(
295        '.pan-and-zoom-content',
296        {
297          ref: this.PAN_ZOOM_CONTENT_REF,
298          onclick: () => {
299            // We don't want to deselect when panning/drag selecting.
300            if (this.keepCurrentSelection) {
301              this.keepCurrentSelection = false;
302              return;
303            }
304            attrs.trace.selection.clear();
305          },
306        },
307        m(
308          '.pf-timeline-header',
309          m(PanelContainer, {
310            trace: attrs.trace,
311            className: 'header-panel-container',
312            panels: removeFalsyValues([
313              OVERVIEW_PANEL_FLAG.get() && this.overviewTimelinePanel,
314              this.timeAxisPanel,
315              this.timeSelectionPanel,
316              this.notesPanel,
317              this.tickmarkPanel,
318            ]),
319            selectedYRange: this.getYRange('header-panel-container'),
320          }),
321          m('.scrollbar-spacer-vertical'),
322        ),
323        m(PanelContainer, {
324          trace: attrs.trace,
325          className: 'pinned-panel-container',
326          panels: AppImpl.instance.isLoadingTrace
327            ? []
328            : attrs.trace.workspace.pinnedTracks.map((trackNode) => {
329                if (trackNode.uri) {
330                  const tr = attrs.trace.tracks.getTrackRenderer(trackNode.uri);
331                  return new TrackPanel({
332                    trace: attrs.trace,
333                    reorderable: true,
334                    node: trackNode,
335                    trackRenderer: tr,
336                    revealOnCreate: true,
337                    indentationLevel: 0,
338                    topOffsetPx: 0,
339                  });
340                } else {
341                  return new TrackPanel({
342                    trace: attrs.trace,
343                    node: trackNode,
344                    revealOnCreate: true,
345                    indentationLevel: 0,
346                    topOffsetPx: 0,
347                  });
348                }
349              }),
350          renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size),
351          renderOverlay: (ctx, size, panels) =>
352            renderOverlay(
353              attrs.trace,
354              ctx,
355              size,
356              panels,
357              attrs.trace.workspace.pinnedTracksNode,
358            ),
359          selectedYRange: this.getYRange('pinned-panel-container'),
360        }),
361        m(PanelContainer, {
362          trace: attrs.trace,
363          className: 'scrolling-panel-container',
364          panels: AppImpl.instance.isLoadingTrace ? [] : scrollingPanels,
365          onPanelStackResize: (width) => {
366            const timelineWidth = width - TRACK_SHELL_WIDTH;
367            this.timelineWidthPx = timelineWidth;
368          },
369          renderUnderlay: (ctx, size) => renderUnderlay(attrs.trace, ctx, size),
370          renderOverlay: (ctx, size, panels) =>
371            renderOverlay(
372              attrs.trace,
373              ctx,
374              size,
375              panels,
376              attrs.trace.workspace.tracks,
377            ),
378          selectedYRange: this.getYRange('scrolling-panel-container'),
379        }),
380      ),
381      m(TabPanel, {
382        trace: attrs.trace,
383      }),
384      this.showPanningHint && m(HelpPanningNotification),
385    );
386
387    attrs.trace.tracks.flushOldTracks();
388    return result;
389  }
390
391  private getYRange(cls: string): VerticalBounds | undefined {
392    if (this.selectedContainer?.containerClass !== cls) {
393      return undefined;
394    }
395    const {dragStartAbsY, dragEndAbsY} = this.selectedContainer;
396    return {
397      top: Math.min(dragStartAbsY, dragEndAbsY),
398      bottom: Math.max(dragStartAbsY, dragEndAbsY),
399    };
400  }
401}
402
403function renderUnderlay(
404  trace: TraceImpl,
405  ctx: CanvasRenderingContext2D,
406  canvasSize: Size2D,
407): void {
408  const size = {
409    width: canvasSize.width - TRACK_SHELL_WIDTH,
410    height: canvasSize.height,
411  };
412
413  using _ = canvasSave(ctx);
414  ctx.translate(TRACK_SHELL_WIDTH, 0);
415
416  const timewindow = trace.timeline.visibleWindow;
417  const timescale = new TimeScale(timewindow, {left: 0, right: size.width});
418
419  // Just render the gridlines - these should appear underneath all tracks
420  drawGridLines(trace, ctx, timewindow.toTimeSpan(), timescale, size);
421}
422
423function renderOverlay(
424  trace: TraceImpl,
425  ctx: CanvasRenderingContext2D,
426  canvasSize: Size2D,
427  panels: ReadonlyArray<RenderedPanelInfo>,
428  trackContainer: TrackNode,
429): void {
430  const size = {
431    width: canvasSize.width - TRACK_SHELL_WIDTH,
432    height: canvasSize.height,
433  };
434
435  using _ = canvasSave(ctx);
436  ctx.translate(TRACK_SHELL_WIDTH, 0);
437  canvasClip(ctx, 0, 0, size.width, size.height);
438
439  // TODO(primiano): plumb the TraceImpl obj throughout the viwer page.
440  renderFlows(trace, ctx, size, panels, trackContainer);
441
442  const timewindow = trace.timeline.visibleWindow;
443  const timescale = new TimeScale(timewindow, {left: 0, right: size.width});
444
445  renderHoveredNoteVertical(trace, ctx, timescale, size);
446  renderHoveredCursorVertical(trace, ctx, timescale, size);
447  renderWakeupVertical(trace, ctx, timescale, size);
448  renderNoteVerticals(trace, ctx, timescale, size);
449}
450
451// Render the toplevel "scrolling" tracks and track groups
452function renderToplevelPanels(trace: TraceImpl): PanelOrGroup[] {
453  return renderNodes(trace, trace.workspace.children, 0, 0);
454}
455
456// Given a list of tracks and a filter term, return a list pf panels filtered by
457// the filter term
458function renderNodes(
459  trace: TraceImpl,
460  nodes: ReadonlyArray<TrackNode>,
461  indent: number,
462  topOffsetPx: number,
463): PanelOrGroup[] {
464  return nodes.flatMap((node) => {
465    if (node.headless) {
466      // Render children as if this node doesn't exist
467      return renderNodes(trace, node.children, indent, topOffsetPx);
468    } else if (node.children.length === 0) {
469      return renderTrackPanel(trace, node, indent, topOffsetPx);
470    } else {
471      const headerPanel = renderTrackPanel(trace, node, indent, topOffsetPx);
472      const isSticky = node.isSummary;
473      const nextTopOffsetPx = isSticky
474        ? topOffsetPx + headerPanel.heightPx
475        : topOffsetPx;
476      return {
477        kind: 'group',
478        collapsed: node.collapsed,
479        header: headerPanel,
480        sticky: isSticky, // && node.collapsed??
481        topOffsetPx,
482        childPanels: node.collapsed
483          ? []
484          : renderNodes(trace, node.children, indent + 1, nextTopOffsetPx),
485      };
486    }
487  });
488}
489
490function renderTrackPanel(
491  trace: TraceImpl,
492  trackNode: TrackNode,
493  indent: number,
494  topOffsetPx: number,
495) {
496  let tr = undefined;
497  if (trackNode.uri) {
498    tr = trace.tracks.getTrackRenderer(trackNode.uri);
499  }
500  return new TrackPanel({
501    trace,
502    node: trackNode,
503    trackRenderer: tr,
504    indentationLevel: indent,
505    topOffsetPx,
506  });
507}
508
509export function drawGridLines(
510  trace: TraceImpl,
511  ctx: CanvasRenderingContext2D,
512  timespan: TimeSpan,
513  timescale: TimeScale,
514  size: Size2D,
515): void {
516  ctx.strokeStyle = TRACK_BORDER_COLOR;
517  ctx.lineWidth = 1;
518
519  if (size.width > 0 && timespan.duration > 0n) {
520    const maxMajorTicks = getMaxMajorTicks(size.width);
521    const offset = trace.timeline.timestampOffset();
522    for (const {type, time} of generateTicks(timespan, maxMajorTicks, offset)) {
523      const px = Math.floor(timescale.timeToPx(time));
524      if (type === TickType.MAJOR) {
525        ctx.beginPath();
526        ctx.moveTo(px + 0.5, 0);
527        ctx.lineTo(px + 0.5, size.height);
528        ctx.stroke();
529      }
530    }
531  }
532}
533
534export function renderHoveredCursorVertical(
535  trace: TraceImpl,
536  ctx: CanvasRenderingContext2D,
537  timescale: TimeScale,
538  size: Size2D,
539) {
540  if (trace.timeline.hoverCursorTimestamp !== undefined) {
541    drawVerticalLineAtTime(
542      ctx,
543      timescale,
544      trace.timeline.hoverCursorTimestamp,
545      size.height,
546      `#344596`,
547    );
548  }
549}
550
551export function renderHoveredNoteVertical(
552  trace: TraceImpl,
553  ctx: CanvasRenderingContext2D,
554  timescale: TimeScale,
555  size: Size2D,
556) {
557  if (trace.timeline.hoveredNoteTimestamp !== undefined) {
558    drawVerticalLineAtTime(
559      ctx,
560      timescale,
561      trace.timeline.hoveredNoteTimestamp,
562      size.height,
563      `#aaa`,
564    );
565  }
566}
567
568export function renderWakeupVertical(
569  trace: TraceImpl,
570  ctx: CanvasRenderingContext2D,
571  timescale: TimeScale,
572  size: Size2D,
573) {
574  const selection = trace.selection.selection;
575  if (selection.kind === 'track_event' && selection.wakeupTs) {
576    drawVerticalLineAtTime(
577      ctx,
578      timescale,
579      selection.wakeupTs,
580      size.height,
581      `black`,
582    );
583  }
584}
585
586export function renderNoteVerticals(
587  trace: TraceImpl,
588  ctx: CanvasRenderingContext2D,
589  timescale: TimeScale,
590  size: Size2D,
591) {
592  // All marked areas should have semi-transparent vertical lines
593  // marking the start and end.
594  for (const note of trace.notes.notes.values()) {
595    if (note.noteType === 'SPAN') {
596      const transparentNoteColor =
597        'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)';
598      drawVerticalLineAtTime(
599        ctx,
600        timescale,
601        note.start,
602        size.height,
603        transparentNoteColor,
604        1,
605      );
606      drawVerticalLineAtTime(
607        ctx,
608        timescale,
609        note.end,
610        size.height,
611        transparentNoteColor,
612        1,
613      );
614    } else if (note.noteType === 'DEFAULT') {
615      drawVerticalLineAtTime(
616        ctx,
617        timescale,
618        note.timestamp,
619        size.height,
620        note.color,
621      );
622    }
623  }
624}
625
626class HelpPanningNotification implements m.ClassComponent {
627  private readonly PANNING_HINT_KEY = 'dismissedPanningHint';
628  private dismissed = localStorage.getItem(this.PANNING_HINT_KEY) === 'true';
629
630  view() {
631    // Do not show the help notification in embedded mode because local storage
632    // does not persist for iFrames. The host is responsible for communicating
633    // to users that they can press '?' for help.
634    if (AppImpl.instance.embeddedMode || this.dismissed) {
635      return;
636    }
637    return m(
638      '.helpful-hint',
639      m(
640        '.hint-text',
641        'Are you trying to pan? Use the WASD keys or hold shift to click ' +
642          "and drag. Press '?' for more help.",
643      ),
644      m(
645        'button.hint-dismiss-button',
646        {
647          onclick: () => {
648            this.dismissed = true;
649            localStorage.setItem(this.PANNING_HINT_KEY, 'true');
650            raf.scheduleFullRedraw();
651          },
652        },
653        'Dismiss',
654      ),
655    );
656  }
657}
658