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