• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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';
16
17import {assertExists, assertFalse, assertTrue} from '../base/logging';
18
19import {
20  SELECTION_STROKE_COLOR,
21  TOPBAR_HEIGHT,
22  TRACK_SHELL_WIDTH,
23} from './css_constants';
24import {
25  FlowEventsRenderer,
26  FlowEventsRendererArgs,
27} from './flow_events_renderer';
28import {globals} from './globals';
29import {isPanelVNode, Panel, PanelSize} from './panel';
30import {
31  debugNow,
32  perfDebug,
33  perfDisplay,
34  RunningStatistics,
35  runningStatStr,
36} from './perf';
37import {TrackGroupAttrs} from './viewer_page';
38
39// If the panel container scrolls, the backing canvas height is
40// SCROLLING_CANVAS_OVERDRAW_FACTOR * parent container height.
41const SCROLLING_CANVAS_OVERDRAW_FACTOR = 1.2;
42
43// We need any here so we can accept vnodes with arbitrary attrs.
44export type AnyAttrsVnode = m.Vnode<any, any>;
45
46export interface Attrs {
47  panels: AnyAttrsVnode[];
48  doesScroll: boolean;
49  kind: 'TRACKS'|'OVERVIEW'|'DETAILS';
50}
51
52interface PanelInfo {
53  id: string;  // Can be == '' for singleton panels.
54  vnode: AnyAttrsVnode;
55  height: number;
56  width: number;
57  x: number;
58  y: number;
59}
60
61export class PanelContainer implements m.ClassComponent<Attrs> {
62  // These values are updated with proper values in oncreate.
63  private parentWidth = 0;
64  private parentHeight = 0;
65  private scrollTop = 0;
66  private panelInfos: PanelInfo[] = [];
67  private panelContainerTop = 0;
68  private panelContainerHeight = 0;
69  private panelByKey = new Map<string, AnyAttrsVnode>();
70  private totalPanelHeight = 0;
71  private canvasHeight = 0;
72
73  private flowEventsRenderer: FlowEventsRenderer;
74
75  private panelPerfStats = new WeakMap<Panel, RunningStatistics>();
76  private perfStats = {
77    totalPanels: 0,
78    panelsOnCanvas: 0,
79    renderStats: new RunningStatistics(10),
80  };
81
82  // Attrs received in the most recent mithril redraw. We receive a new vnode
83  // with new attrs on every redraw, and we cache it here so that resize
84  // listeners and canvas redraw callbacks can access it.
85  private attrs: Attrs;
86
87  private ctx?: CanvasRenderingContext2D;
88
89  private onResize: () => void = () => {};
90  private parentOnScroll: () => void = () => {};
91  private canvasRedrawer: () => void;
92
93  get canvasOverdrawFactor() {
94    return this.attrs.doesScroll ? SCROLLING_CANVAS_OVERDRAW_FACTOR : 1;
95  }
96
97  getPanelsInRegion(startX: number, endX: number, startY: number, endY: number):
98      AnyAttrsVnode[] {
99    const minX = Math.min(startX, endX);
100    const maxX = Math.max(startX, endX);
101    const minY = Math.min(startY, endY);
102    const maxY = Math.max(startY, endY);
103    const panels: AnyAttrsVnode[] = [];
104    for (let i = 0; i < this.panelInfos.length; i++) {
105      const pos = this.panelInfos[i];
106      const realPosX = pos.x - TRACK_SHELL_WIDTH;
107      if (realPosX + pos.width >= minX && realPosX <= maxX &&
108          pos.y + pos.height >= minY && pos.y <= maxY &&
109          pos.vnode.attrs.selectable) {
110        panels.push(pos.vnode);
111      }
112    }
113    return panels;
114  }
115
116  // This finds the tracks covered by the in-progress area selection. When
117  // editing areaY is not set, so this will not be used.
118  handleAreaSelection() {
119    const area = globals.frontendLocalState.selectedArea;
120    if (area === undefined ||
121        globals.frontendLocalState.areaY.start === undefined ||
122        globals.frontendLocalState.areaY.end === undefined ||
123        this.panelInfos.length === 0) {
124      return;
125    }
126    // Only get panels from the current panel container if the selection began
127    // in this container.
128    const panelContainerTop = this.panelInfos[0].y;
129    const panelContainerBottom = this.panelInfos[this.panelInfos.length - 1].y +
130        this.panelInfos[this.panelInfos.length - 1].height;
131    if (globals.frontendLocalState.areaY.start + TOPBAR_HEIGHT <
132            panelContainerTop ||
133        globals.frontendLocalState.areaY.start + TOPBAR_HEIGHT >
134            panelContainerBottom) {
135      return;
136    }
137
138    const {visibleTimeScale} = globals.frontendLocalState;
139
140    // The Y value is given from the top of the pan and zoom region, we want it
141    // from the top of the panel container. The parent offset corrects that.
142    const panels = this.getPanelsInRegion(
143        visibleTimeScale.tpTimeToPx(area.start),
144        visibleTimeScale.tpTimeToPx(area.end),
145        globals.frontendLocalState.areaY.start + TOPBAR_HEIGHT,
146        globals.frontendLocalState.areaY.end + TOPBAR_HEIGHT);
147    // Get the track ids from the panels.
148    const tracks = [];
149    for (const panel of panels) {
150      if (panel.attrs.id !== undefined) {
151        tracks.push(panel.attrs.id);
152        continue;
153      }
154      if (panel.attrs.trackGroupId !== undefined) {
155        const trackGroup = globals.state.trackGroups[panel.attrs.trackGroupId];
156        // Only select a track group and all child tracks if it is closed.
157        if (trackGroup.collapsed) {
158          tracks.push(panel.attrs.trackGroupId);
159          for (const track of trackGroup.tracks) {
160            tracks.push(track);
161          }
162        }
163      }
164    }
165    globals.frontendLocalState.selectArea(area.start, area.end, tracks);
166  }
167
168  constructor(vnode: m.CVnode<Attrs>) {
169    this.attrs = vnode.attrs;
170    this.canvasRedrawer = () => this.redrawCanvas();
171    globals.rafScheduler.addRedrawCallback(this.canvasRedrawer);
172    perfDisplay.addContainer(this);
173    this.flowEventsRenderer = new FlowEventsRenderer();
174  }
175
176  oncreate(vnodeDom: m.CVnodeDOM<Attrs>) {
177    // Save the canvas context in the state.
178    const canvas =
179        vnodeDom.dom.querySelector('.main-canvas') as HTMLCanvasElement;
180    const ctx = canvas.getContext('2d');
181    if (!ctx) {
182      throw Error('Cannot create canvas context');
183    }
184    this.ctx = ctx;
185
186    this.readParentSizeFromDom(vnodeDom.dom);
187    this.readPanelHeightsFromDom(vnodeDom.dom);
188
189    this.updateCanvasDimensions();
190    this.repositionCanvas();
191
192    // Save the resize handler in the state so we can remove it later.
193    // TODO: Encapsulate resize handling better.
194    this.onResize = () => {
195      this.readParentSizeFromDom(vnodeDom.dom);
196      this.updateCanvasDimensions();
197      this.repositionCanvas();
198      globals.rafScheduler.scheduleFullRedraw();
199    };
200
201    // Once ResizeObservers are out, we can stop accessing the window here.
202    window.addEventListener('resize', this.onResize);
203
204    // TODO(dproy): Handle change in doesScroll attribute.
205    if (this.attrs.doesScroll) {
206      this.parentOnScroll = () => {
207        this.scrollTop = assertExists(vnodeDom.dom.parentElement).scrollTop;
208        this.repositionCanvas();
209        globals.rafScheduler.scheduleRedraw();
210      };
211      vnodeDom.dom.parentElement!.addEventListener(
212          'scroll', this.parentOnScroll, {passive: true});
213    }
214  }
215
216  onremove({attrs, dom}: m.CVnodeDOM<Attrs>) {
217    window.removeEventListener('resize', this.onResize);
218    globals.rafScheduler.removeRedrawCallback(this.canvasRedrawer);
219    if (attrs.doesScroll) {
220      dom.parentElement!.removeEventListener('scroll', this.parentOnScroll);
221    }
222    perfDisplay.removeContainer(this);
223  }
224
225  isTrackGroupAttrs(attrs: unknown): attrs is TrackGroupAttrs {
226    return (attrs as {collapsed?: boolean}).collapsed !== undefined;
227  }
228
229  renderPanel(node: AnyAttrsVnode, key: string, extraClass = ''): m.Vnode {
230    assertFalse(this.panelByKey.has(key));
231    this.panelByKey.set(key, node);
232
233    return m(
234        `.panel${extraClass}`,
235        {key, 'data-key': key},
236        perfDebug() ?
237            [node, m('.debug-panel-border', {key: 'debug-panel-border'})] :
238            node);
239  }
240
241  // Render a tree of panels into one vnode. Argument `path` is used to build
242  // `key` attribute for intermediate tree vnodes: otherwise Mithril internals
243  // will complain about keyed and non-keyed vnodes mixed together.
244  renderTree(node: AnyAttrsVnode, path: string): m.Vnode {
245    if (this.isTrackGroupAttrs(node.attrs)) {
246      return m(
247          'div',
248          {key: path},
249          this.renderPanel(
250              node.attrs.header,
251              `${path}-header`,
252              node.attrs.collapsed ? '' : '.sticky'),
253          ...node.attrs.childTracks.map(
254              (child, index) => this.renderTree(child, `${path}-${index}`)));
255    }
256    return this.renderPanel(node, assertExists(node.key) as string);
257  }
258
259  view({attrs}: m.CVnode<Attrs>) {
260    this.attrs = attrs;
261    this.panelByKey.clear();
262    const children = attrs.panels.map(
263        (panel, index) => this.renderTree(panel, `track-tree-${index}`));
264
265    return [
266      m(
267          '.scroll-limiter',
268          m('canvas.main-canvas'),
269          ),
270      m('.panels', children),
271    ];
272  }
273
274  onupdate(vnodeDom: m.CVnodeDOM<Attrs>) {
275    const totalPanelHeightChanged = this.readPanelHeightsFromDom(vnodeDom.dom);
276    const parentSizeChanged = this.readParentSizeFromDom(vnodeDom.dom);
277    const canvasSizeShouldChange =
278        parentSizeChanged || !this.attrs.doesScroll && totalPanelHeightChanged;
279    if (canvasSizeShouldChange) {
280      this.updateCanvasDimensions();
281      this.repositionCanvas();
282      if (this.attrs.kind === 'TRACKS') {
283        globals.frontendLocalState.updateLocalLimits(
284            0, this.parentWidth - TRACK_SHELL_WIDTH);
285      }
286      this.redrawCanvas();
287    }
288  }
289
290  private updateCanvasDimensions() {
291    this.canvasHeight = Math.floor(
292        this.attrs.doesScroll ? this.parentHeight * this.canvasOverdrawFactor :
293                                this.totalPanelHeight);
294    const ctx = assertExists(this.ctx);
295    const canvas = assertExists(ctx.canvas);
296    canvas.style.height = `${this.canvasHeight}px`;
297
298    // If're we're non-scrolling canvas and the scroll-limiter should always
299    // have the same height. Enforce this by explicitly setting the height.
300    if (!this.attrs.doesScroll) {
301      const scrollLimiter = canvas.parentElement;
302      if (scrollLimiter) {
303        scrollLimiter.style.height = `${this.canvasHeight}px`;
304      }
305    }
306
307    const dpr = window.devicePixelRatio;
308    ctx.canvas.width = this.parentWidth * dpr;
309    ctx.canvas.height = this.canvasHeight * dpr;
310    ctx.scale(dpr, dpr);
311  }
312
313  private repositionCanvas() {
314    const canvas = assertExists(assertExists(this.ctx).canvas);
315    const canvasYStart =
316        Math.floor(this.scrollTop - this.getCanvasOverdrawHeightPerSide());
317    canvas.style.transform = `translateY(${canvasYStart}px)`;
318  }
319
320  // Reads dimensions of parent node. Returns true if read dimensions are
321  // different from what was cached in the state.
322  private readParentSizeFromDom(dom: Element): boolean {
323    const oldWidth = this.parentWidth;
324    const oldHeight = this.parentHeight;
325    const clientRect = assertExists(dom.parentElement).getBoundingClientRect();
326    // On non-MacOS if there is a solid scroll bar it can cover important
327    // pixels, reduce the size of the canvas so it doesn't overlap with
328    // the scroll bar.
329    this.parentWidth =
330        clientRect.width - globals.frontendLocalState.getScrollbarWidth();
331    this.parentHeight = clientRect.height;
332    return this.parentHeight !== oldHeight || this.parentWidth !== oldWidth;
333  }
334
335  // Reads dimensions of panels. Returns true if total panel height is different
336  // from what was cached in state.
337  private readPanelHeightsFromDom(dom: Element): boolean {
338    const prevHeight = this.totalPanelHeight;
339    this.panelInfos = [];
340    this.totalPanelHeight = 0;
341    const domRect = dom.getBoundingClientRect();
342    this.panelContainerTop = domRect.y;
343    this.panelContainerHeight = domRect.height;
344
345    dom.parentElement!.querySelectorAll('.panel').forEach((panel) => {
346      const key = assertExists(panel.getAttribute('data-key'));
347      const vnode = assertExists(this.panelByKey.get(key));
348
349      // NOTE: the id can be undefined for singletons like overview timeline.
350      const id = vnode.attrs.id || vnode.attrs.trackGroupId || '';
351      const rect = panel.getBoundingClientRect();
352      this.panelInfos.push({
353        id,
354        height: rect.height,
355        width: rect.width,
356        x: rect.x,
357        y: rect.y,
358        vnode,
359      });
360      this.totalPanelHeight += rect.height;
361    });
362
363    return this.totalPanelHeight !== prevHeight;
364  }
365
366  private overlapsCanvas(yStart: number, yEnd: number) {
367    return yEnd > 0 && yStart < this.canvasHeight;
368  }
369
370  private redrawCanvas() {
371    const redrawStart = debugNow();
372    if (!this.ctx) return;
373    this.ctx.clearRect(0, 0, this.parentWidth, this.canvasHeight);
374    const canvasYStart =
375        Math.floor(this.scrollTop - this.getCanvasOverdrawHeightPerSide());
376
377    this.handleAreaSelection();
378
379    let panelYStart = 0;
380    let totalOnCanvas = 0;
381    const flowEventsRendererArgs =
382        new FlowEventsRendererArgs(this.parentWidth, this.canvasHeight);
383    for (let i = 0; i < this.panelInfos.length; i++) {
384      const panel = this.panelInfos[i].vnode;
385      const panelHeight = this.panelInfos[i].height;
386      const yStartOnCanvas = panelYStart - canvasYStart;
387
388      if (!isPanelVNode(panel)) {
389        throw new Error('Vnode passed to panel container is not a panel');
390      }
391
392      flowEventsRendererArgs.registerPanel(panel, yStartOnCanvas, panelHeight);
393
394      if (!this.overlapsCanvas(yStartOnCanvas, yStartOnCanvas + panelHeight)) {
395        panelYStart += panelHeight;
396        continue;
397      }
398
399      totalOnCanvas++;
400
401      this.ctx.save();
402      this.ctx.translate(0, yStartOnCanvas);
403      const clipRect = new Path2D();
404      const size = {width: this.parentWidth, height: panelHeight};
405      clipRect.rect(0, 0, size.width, size.height);
406      this.ctx.clip(clipRect);
407      const beforeRender = debugNow();
408      panel.state.renderCanvas(this.ctx, size, panel);
409      this.updatePanelStats(
410          i, panel.state, debugNow() - beforeRender, this.ctx, size);
411      this.ctx.restore();
412      panelYStart += panelHeight;
413    }
414
415    this.drawTopLayerOnCanvas();
416    this.flowEventsRenderer.render(this.ctx, flowEventsRendererArgs);
417    // Collect performance as the last thing we do.
418    const redrawDur = debugNow() - redrawStart;
419    this.updatePerfStats(redrawDur, this.panelInfos.length, totalOnCanvas);
420  }
421
422  // The panels each draw on the canvas but some details need to be drawn across
423  // the whole canvas rather than per panel.
424  private drawTopLayerOnCanvas() {
425    if (!this.ctx) return;
426    const area = globals.frontendLocalState.selectedArea;
427    if (area === undefined ||
428        globals.frontendLocalState.areaY.start === undefined ||
429        globals.frontendLocalState.areaY.end === undefined) {
430      return;
431    }
432    if (this.panelInfos.length === 0 || area.tracks.length === 0) return;
433
434    // Find the minY and maxY of the selected tracks in this panel container.
435    let selectedTracksMinY = this.panelContainerHeight + this.panelContainerTop;
436    let selectedTracksMaxY = this.panelContainerTop;
437    let trackFromCurrentContainerSelected = false;
438    for (let i = 0; i < this.panelInfos.length; i++) {
439      if (area.tracks.includes(this.panelInfos[i].id)) {
440        trackFromCurrentContainerSelected = true;
441        selectedTracksMinY = Math.min(selectedTracksMinY, this.panelInfos[i].y);
442        selectedTracksMaxY = Math.max(
443            selectedTracksMaxY,
444            this.panelInfos[i].y + this.panelInfos[i].height);
445      }
446    }
447
448    // No box should be drawn if there are no selected tracks in the current
449    // container.
450    if (!trackFromCurrentContainerSelected) {
451      return;
452    }
453
454    const {visibleTimeScale} = globals.frontendLocalState;
455    const startX = visibleTimeScale.tpTimeToPx(area.start);
456    const endX = visibleTimeScale.tpTimeToPx(area.end);
457    // To align with where to draw on the canvas subtract the first panel Y.
458    selectedTracksMinY -= this.panelContainerTop;
459    selectedTracksMaxY -= this.panelContainerTop;
460    this.ctx.save();
461    this.ctx.strokeStyle = SELECTION_STROKE_COLOR;
462    this.ctx.lineWidth = 1;
463    const canvasYStart =
464        Math.floor(this.scrollTop - this.getCanvasOverdrawHeightPerSide());
465    this.ctx.translate(TRACK_SHELL_WIDTH, -canvasYStart);
466    this.ctx.strokeRect(
467        startX,
468        selectedTracksMaxY,
469        endX - startX,
470        selectedTracksMinY - selectedTracksMaxY);
471    this.ctx.restore();
472  }
473
474  private updatePanelStats(
475      panelIndex: number, panel: Panel, renderTime: number,
476      ctx: CanvasRenderingContext2D, size: PanelSize) {
477    if (!perfDebug()) return;
478    let renderStats = this.panelPerfStats.get(panel);
479    if (renderStats === undefined) {
480      renderStats = new RunningStatistics();
481      this.panelPerfStats.set(panel, renderStats);
482    }
483    renderStats.addValue(renderTime);
484
485    const statW = 300;
486    ctx.fillStyle = 'hsl(97, 100%, 96%)';
487    ctx.fillRect(size.width - statW, size.height - 20, statW, 20);
488    ctx.fillStyle = 'hsla(122, 77%, 22%)';
489    const statStr = `Panel ${panelIndex + 1} | ` + runningStatStr(renderStats);
490    ctx.fillText(statStr, size.width - statW, size.height - 10);
491  }
492
493  private updatePerfStats(
494      renderTime: number, totalPanels: number, panelsOnCanvas: number) {
495    if (!perfDebug()) return;
496    this.perfStats.renderStats.addValue(renderTime);
497    this.perfStats.totalPanels = totalPanels;
498    this.perfStats.panelsOnCanvas = panelsOnCanvas;
499  }
500
501  renderPerfStats(index: number) {
502    assertTrue(perfDebug());
503    return [m(
504        'section',
505        m('div', `Panel Container ${index + 1}`),
506        m('div',
507          `${this.perfStats.totalPanels} panels, ` +
508              `${this.perfStats.panelsOnCanvas} on canvas.`),
509        m('div', runningStatStr(this.perfStats.renderStats)))];
510  }
511
512  private getCanvasOverdrawHeightPerSide() {
513    const overdrawHeight = (this.canvasOverdrawFactor - 1) * this.parentHeight;
514    return overdrawHeight / 2;
515  }
516}
517