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