xref: /aosp_15_r20/external/perfetto/ui/src/frontend/panel_container.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 m from 'mithril';
16import {findRef, toHTMLElement} from '../base/dom_utils';
17import {assertExists, assertFalse} from '../base/logging';
18import {
19  PerfStats,
20  PerfStatsContainer,
21  runningStatStr,
22} from '../core/perf_stats';
23import {raf} from '../core/raf_scheduler';
24import {SimpleResizeObserver} from '../base/resize_observer';
25import {canvasClip} from '../base/canvas_utils';
26import {SELECTION_STROKE_COLOR, TRACK_SHELL_WIDTH} from './css_constants';
27import {Bounds2D, Size2D, VerticalBounds} from '../base/geom';
28import {VirtualCanvas} from './virtual_canvas';
29import {DisposableStack} from '../base/disposable_stack';
30import {TimeScale} from '../base/time_scale';
31import {TrackNode} from '../public/workspace';
32import {HTMLAttrs} from '../widgets/common';
33import {TraceImpl, TraceImplAttrs} from '../core/trace_impl';
34
35const CANVAS_OVERDRAW_PX = 100;
36
37export interface Panel {
38  readonly kind: 'panel';
39  render(): m.Children;
40  readonly selectable: boolean;
41  // TODO(stevegolton): Remove this - panel container should know nothing of
42  // tracks!
43  readonly trackNode?: TrackNode;
44  renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D): void;
45  getSliceVerticalBounds?(depth: number): VerticalBounds | undefined;
46}
47
48export interface PanelGroup {
49  readonly kind: 'group';
50  readonly collapsed: boolean;
51  readonly header?: Panel;
52  readonly topOffsetPx: number;
53  readonly sticky: boolean;
54  readonly childPanels: PanelOrGroup[];
55}
56
57export type PanelOrGroup = Panel | PanelGroup;
58
59export interface PanelContainerAttrs extends TraceImplAttrs {
60  panels: PanelOrGroup[];
61  className?: string;
62  selectedYRange: VerticalBounds | undefined;
63
64  onPanelStackResize?: (width: number, height: number) => void;
65
66  // Called after all panels have been rendered to the canvas, to give the
67  // caller the opportunity to render an overlay on top of the panels.
68  renderOverlay?(
69    ctx: CanvasRenderingContext2D,
70    size: Size2D,
71    panels: ReadonlyArray<RenderedPanelInfo>,
72  ): void;
73
74  // Called before the panels are rendered
75  renderUnderlay?(ctx: CanvasRenderingContext2D, size: Size2D): void;
76}
77
78interface PanelInfo {
79  trackNode?: TrackNode; // Can be undefined for singleton panels.
80  panel: Panel;
81  height: number;
82  width: number;
83  clientX: number;
84  clientY: number;
85  absY: number;
86}
87
88export interface RenderedPanelInfo {
89  panel: Panel;
90  rect: Bounds2D;
91}
92
93export class PanelContainer
94  implements m.ClassComponent<PanelContainerAttrs>, PerfStatsContainer
95{
96  private readonly trace: TraceImpl;
97  private attrs: PanelContainerAttrs;
98
99  // Updated every render cycle in the view() hook
100  private panelById = new Map<string, Panel>();
101
102  // Updated every render cycle in the oncreate/onupdate hook
103  private panelInfos: PanelInfo[] = [];
104
105  private perfStatsEnabled = false;
106  private panelPerfStats = new WeakMap<Panel, PerfStats>();
107  private perfStats = {
108    totalPanels: 0,
109    panelsOnCanvas: 0,
110    renderStats: new PerfStats(10),
111  };
112
113  private ctx?: CanvasRenderingContext2D;
114
115  private readonly trash = new DisposableStack();
116
117  private readonly OVERLAY_REF = 'overlay';
118  private readonly PANEL_STACK_REF = 'panel-stack';
119
120  constructor({attrs}: m.CVnode<PanelContainerAttrs>) {
121    this.attrs = attrs;
122    this.trace = attrs.trace;
123    this.trash.use(raf.addCanvasRedrawCallback(() => this.renderCanvas()));
124    this.trash.use(attrs.trace.perfDebugging.addContainer(this));
125  }
126
127  getPanelsInRegion(
128    startX: number,
129    endX: number,
130    startY: number,
131    endY: number,
132  ): Panel[] {
133    const minX = Math.min(startX, endX);
134    const maxX = Math.max(startX, endX);
135    const minY = Math.min(startY, endY);
136    const maxY = Math.max(startY, endY);
137    const panels: Panel[] = [];
138    for (let i = 0; i < this.panelInfos.length; i++) {
139      const pos = this.panelInfos[i];
140      const realPosX = pos.clientX - TRACK_SHELL_WIDTH;
141      if (
142        realPosX + pos.width >= minX &&
143        realPosX <= maxX &&
144        pos.absY + pos.height >= minY &&
145        pos.absY <= maxY &&
146        pos.panel.selectable
147      ) {
148        panels.push(pos.panel);
149      }
150    }
151    return panels;
152  }
153
154  // This finds the tracks covered by the in-progress area selection. When
155  // editing areaY is not set, so this will not be used.
156  handleAreaSelection() {
157    const {selectedYRange} = this.attrs;
158    const area = this.trace.timeline.selectedArea;
159    if (
160      area === undefined ||
161      selectedYRange === undefined ||
162      this.panelInfos.length === 0
163    ) {
164      return;
165    }
166
167    // TODO(stevegolton): We shouldn't know anything about visible time scale
168    // right now, that's a job for our parent, but we can put one together so we
169    // don't have to refactor this entire bit right now...
170
171    const visibleTimeScale = new TimeScale(this.trace.timeline.visibleWindow, {
172      left: 0,
173      right: this.virtualCanvas!.size.width - TRACK_SHELL_WIDTH,
174    });
175
176    // The Y value is given from the top of the pan and zoom region, we want it
177    // from the top of the panel container. The parent offset corrects that.
178    const panels = this.getPanelsInRegion(
179      visibleTimeScale.timeToPx(area.start),
180      visibleTimeScale.timeToPx(area.end),
181      selectedYRange.top,
182      selectedYRange.bottom,
183    );
184
185    // Get the track ids from the panels.
186    const trackUris: string[] = [];
187    for (const panel of panels) {
188      if (panel.trackNode) {
189        if (panel.trackNode.isSummary) {
190          const groupNode = panel.trackNode;
191          // Select a track group and all child tracks if it is collapsed
192          if (groupNode.collapsed) {
193            for (const track of groupNode.flatTracks) {
194              track.uri && trackUris.push(track.uri);
195            }
196          }
197        } else {
198          panel.trackNode.uri && trackUris.push(panel.trackNode.uri);
199        }
200      }
201    }
202    this.trace.timeline.selectArea(area.start, area.end, trackUris);
203  }
204
205  private virtualCanvas?: VirtualCanvas;
206
207  oncreate(vnode: m.CVnodeDOM<PanelContainerAttrs>) {
208    const {dom, attrs} = vnode;
209
210    const overlayElement = toHTMLElement(
211      assertExists(findRef(dom, this.OVERLAY_REF)),
212    );
213
214    const virtualCanvas = new VirtualCanvas(overlayElement, dom, {
215      overdrawPx: CANVAS_OVERDRAW_PX,
216    });
217    this.trash.use(virtualCanvas);
218    this.virtualCanvas = virtualCanvas;
219
220    const ctx = virtualCanvas.canvasElement.getContext('2d');
221    if (!ctx) {
222      throw Error('Cannot create canvas context');
223    }
224    this.ctx = ctx;
225
226    virtualCanvas.setCanvasResizeListener((canvas, width, height) => {
227      const dpr = window.devicePixelRatio;
228      canvas.width = width * dpr;
229      canvas.height = height * dpr;
230    });
231
232    virtualCanvas.setLayoutShiftListener(() => {
233      this.renderCanvas();
234    });
235
236    this.onupdate(vnode);
237
238    const panelStackElement = toHTMLElement(
239      assertExists(findRef(dom, this.PANEL_STACK_REF)),
240    );
241
242    // Listen for when the panel stack changes size
243    this.trash.use(
244      new SimpleResizeObserver(panelStackElement, () => {
245        attrs.onPanelStackResize?.(
246          panelStackElement.clientWidth,
247          panelStackElement.clientHeight,
248        );
249      }),
250    );
251  }
252
253  onremove() {
254    this.trash.dispose();
255  }
256
257  renderPanel(node: Panel, panelId: string, htmlAttrs?: HTMLAttrs): m.Vnode {
258    assertFalse(this.panelById.has(panelId));
259    this.panelById.set(panelId, node);
260    return m(
261      `.pf-panel`,
262      {...htmlAttrs, 'data-panel-id': panelId},
263      node.render(),
264    );
265  }
266
267  // Render a tree of panels into one vnode. Argument `path` is used to build
268  // `key` attribute for intermediate tree vnodes: otherwise Mithril internals
269  // will complain about keyed and non-keyed vnodes mixed together.
270  renderTree(node: PanelOrGroup, panelId: string): m.Vnode {
271    if (node.kind === 'group') {
272      const style = {
273        position: 'sticky',
274        top: `${node.topOffsetPx}px`,
275        zIndex: `${2000 - node.topOffsetPx}`,
276      };
277      return m(
278        'div.pf-panel-group',
279        node.header &&
280          this.renderPanel(node.header, `${panelId}-header`, {
281            style: !node.collapsed && node.sticky ? style : {},
282          }),
283        ...node.childPanels.map((child, index) =>
284          this.renderTree(child, `${panelId}-${index}`),
285        ),
286      );
287    }
288    return this.renderPanel(node, panelId);
289  }
290
291  view({attrs}: m.CVnode<PanelContainerAttrs>) {
292    this.attrs = attrs;
293    this.panelById.clear();
294    const children = attrs.panels.map((panel, index) =>
295      this.renderTree(panel, `${index}`),
296    );
297
298    return m(
299      '.pf-panel-container',
300      {className: attrs.className},
301      m(
302        '.pf-panel-stack',
303        {ref: this.PANEL_STACK_REF},
304        m('.pf-overlay', {ref: this.OVERLAY_REF}),
305        children,
306      ),
307    );
308  }
309
310  onupdate({dom}: m.CVnodeDOM<PanelContainerAttrs>) {
311    this.readPanelRectsFromDom(dom);
312  }
313
314  private readPanelRectsFromDom(dom: Element): void {
315    this.panelInfos = [];
316
317    const panel = dom.querySelectorAll('.pf-panel');
318    const panels = assertExists(findRef(dom, this.PANEL_STACK_REF));
319    const {top} = panels.getBoundingClientRect();
320    panel.forEach((panelElement) => {
321      const panelHTMLElement = toHTMLElement(panelElement);
322      const panelId = assertExists(panelHTMLElement.dataset.panelId);
323      const panel = assertExists(this.panelById.get(panelId));
324
325      // NOTE: the id can be undefined for singletons like overview timeline.
326      const rect = panelElement.getBoundingClientRect();
327      this.panelInfos.push({
328        trackNode: panel.trackNode,
329        height: rect.height,
330        width: rect.width,
331        clientX: rect.x,
332        clientY: rect.y,
333        absY: rect.y - top,
334        panel,
335      });
336    });
337  }
338
339  private renderCanvas() {
340    if (!this.ctx) return;
341    if (!this.virtualCanvas) return;
342
343    const ctx = this.ctx;
344    const vc = this.virtualCanvas;
345    const redrawStart = performance.now();
346
347    ctx.resetTransform();
348    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
349
350    const dpr = window.devicePixelRatio;
351    ctx.scale(dpr, dpr);
352    ctx.translate(-vc.canvasRect.left, -vc.canvasRect.top);
353
354    this.handleAreaSelection();
355
356    const totalRenderedPanels = this.renderPanels(ctx, vc);
357    this.drawTopLayerOnCanvas(ctx, vc);
358
359    // Collect performance as the last thing we do.
360    const redrawDur = performance.now() - redrawStart;
361    this.updatePerfStats(
362      redrawDur,
363      this.panelInfos.length,
364      totalRenderedPanels,
365    );
366  }
367
368  private renderPanels(
369    ctx: CanvasRenderingContext2D,
370    vc: VirtualCanvas,
371  ): number {
372    this.attrs.renderUnderlay?.(ctx, vc.size);
373
374    let panelTop = 0;
375    let totalOnCanvas = 0;
376
377    const renderedPanels = Array<RenderedPanelInfo>();
378
379    for (let i = 0; i < this.panelInfos.length; i++) {
380      const {
381        panel,
382        width: panelWidth,
383        height: panelHeight,
384      } = this.panelInfos[i];
385
386      const panelRect = {
387        left: 0,
388        top: panelTop,
389        bottom: panelTop + panelHeight,
390        right: panelWidth,
391      };
392      const panelSize = {width: panelWidth, height: panelHeight};
393
394      if (vc.overlapsCanvas(panelRect)) {
395        totalOnCanvas++;
396
397        ctx.save();
398        ctx.translate(0, panelTop);
399        canvasClip(ctx, 0, 0, panelWidth, panelHeight);
400        const beforeRender = performance.now();
401        panel.renderCanvas(ctx, panelSize);
402        this.updatePanelStats(
403          i,
404          panel,
405          performance.now() - beforeRender,
406          ctx,
407          panelSize,
408        );
409        ctx.restore();
410      }
411
412      renderedPanels.push({
413        panel,
414        rect: {
415          top: panelTop,
416          bottom: panelTop + panelHeight,
417          left: 0,
418          right: panelWidth,
419        },
420      });
421
422      panelTop += panelHeight;
423    }
424
425    this.attrs.renderOverlay?.(ctx, vc.size, renderedPanels);
426
427    return totalOnCanvas;
428  }
429
430  // The panels each draw on the canvas but some details need to be drawn across
431  // the whole canvas rather than per panel.
432  private drawTopLayerOnCanvas(
433    ctx: CanvasRenderingContext2D,
434    vc: VirtualCanvas,
435  ): void {
436    const {selectedYRange} = this.attrs;
437    const area = this.trace.timeline.selectedArea;
438    if (area === undefined || selectedYRange === undefined) {
439      return;
440    }
441    if (this.panelInfos.length === 0 || area.trackUris.length === 0) {
442      return;
443    }
444
445    // Find the minY and maxY of the selected tracks in this panel container.
446    let selectedTracksMinY = selectedYRange.top;
447    let selectedTracksMaxY = selectedYRange.bottom;
448    for (let i = 0; i < this.panelInfos.length; i++) {
449      const trackUri = this.panelInfos[i].trackNode?.uri;
450      if (trackUri && area.trackUris.includes(trackUri)) {
451        selectedTracksMinY = Math.min(
452          selectedTracksMinY,
453          this.panelInfos[i].absY,
454        );
455        selectedTracksMaxY = Math.max(
456          selectedTracksMaxY,
457          this.panelInfos[i].absY + this.panelInfos[i].height,
458        );
459      }
460    }
461
462    // TODO(stevegolton): We shouldn't know anything about visible time scale
463    // right now, that's a job for our parent, but we can put one together so we
464    // don't have to refactor this entire bit right now...
465
466    const visibleTimeScale = new TimeScale(this.trace.timeline.visibleWindow, {
467      left: 0,
468      right: vc.size.width - TRACK_SHELL_WIDTH,
469    });
470
471    const startX = visibleTimeScale.timeToPx(area.start);
472    const endX = visibleTimeScale.timeToPx(area.end);
473    ctx.save();
474    ctx.strokeStyle = SELECTION_STROKE_COLOR;
475    ctx.lineWidth = 1;
476
477    ctx.translate(TRACK_SHELL_WIDTH, 0);
478
479    // Clip off any drawing happening outside the bounds of the timeline area
480    canvasClip(ctx, 0, 0, vc.size.width - TRACK_SHELL_WIDTH, vc.size.height);
481
482    ctx.strokeRect(
483      startX,
484      selectedTracksMaxY,
485      endX - startX,
486      selectedTracksMinY - selectedTracksMaxY,
487    );
488    ctx.restore();
489  }
490
491  private updatePanelStats(
492    panelIndex: number,
493    panel: Panel,
494    renderTime: number,
495    ctx: CanvasRenderingContext2D,
496    size: Size2D,
497  ) {
498    if (!this.perfStatsEnabled) return;
499    let renderStats = this.panelPerfStats.get(panel);
500    if (renderStats === undefined) {
501      renderStats = new PerfStats();
502      this.panelPerfStats.set(panel, renderStats);
503    }
504    renderStats.addValue(renderTime);
505
506    // Draw a green box around the whole panel
507    ctx.strokeStyle = 'rgba(69, 187, 73, 0.5)';
508    const lineWidth = 1;
509    ctx.lineWidth = lineWidth;
510    ctx.strokeRect(
511      lineWidth / 2,
512      lineWidth / 2,
513      size.width - lineWidth,
514      size.height - lineWidth,
515    );
516
517    const statW = 300;
518    ctx.fillStyle = 'hsl(97, 100%, 96%)';
519    ctx.fillRect(size.width - statW, size.height - 20, statW, 20);
520    ctx.fillStyle = 'hsla(122, 77%, 22%)';
521    const statStr = `Panel ${panelIndex + 1} | ` + runningStatStr(renderStats);
522    ctx.fillText(statStr, size.width - statW, size.height - 10);
523  }
524
525  private updatePerfStats(
526    renderTime: number,
527    totalPanels: number,
528    panelsOnCanvas: number,
529  ) {
530    if (!this.perfStatsEnabled) return;
531    this.perfStats.renderStats.addValue(renderTime);
532    this.perfStats.totalPanels = totalPanels;
533    this.perfStats.panelsOnCanvas = panelsOnCanvas;
534  }
535
536  setPerfStatsEnabled(enable: boolean): void {
537    this.perfStatsEnabled = enable;
538  }
539
540  renderPerfStats() {
541    return [
542      m(
543        'div',
544        `${this.perfStats.totalPanels} panels, ` +
545          `${this.perfStats.panelsOnCanvas} on canvas.`,
546      ),
547      m('div', runningStatStr(this.perfStats.renderStats)),
548    ];
549  }
550}
551