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