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 {globals} from './globals'; 20import {isPanelVNode, Panel, PanelSize} from './panel'; 21import { 22 debugNow, 23 perfDebug, 24 perfDisplay, 25 RunningStatistics, 26 runningStatStr 27} from './perf'; 28 29/** 30 * If the panel container scrolls, the backing canvas height is 31 * SCROLLING_CANVAS_OVERDRAW_FACTOR * parent container height. 32 */ 33const SCROLLING_CANVAS_OVERDRAW_FACTOR = 1.2; 34 35// We need any here so we can accept vnodes with arbitrary attrs. 36// tslint:disable-next-line:no-any 37export type AnyAttrsVnode = m.Vnode<any, {}>; 38 39interface Attrs { 40 panels: AnyAttrsVnode[]; 41 doesScroll: boolean; 42} 43 44export class PanelContainer implements m.ClassComponent<Attrs> { 45 // These values are updated with proper values in oncreate. 46 private parentWidth = 0; 47 private parentHeight = 0; 48 private scrollTop = 0; 49 private panelHeights: number[] = []; 50 private totalPanelHeight = 0; 51 private canvasHeight = 0; 52 53 private panelPerfStats = new WeakMap<Panel, RunningStatistics>(); 54 private perfStats = { 55 totalPanels: 0, 56 panelsOnCanvas: 0, 57 renderStats: new RunningStatistics(10), 58 }; 59 60 // Attrs received in the most recent mithril redraw. We receive a new vnode 61 // with new attrs on every redraw, and we cache it here so that resize 62 // listeners and canvas redraw callbacks can access it. 63 private attrs: Attrs; 64 65 private ctx?: CanvasRenderingContext2D; 66 67 private onResize: () => void = () => {}; 68 private parentOnScroll: () => void = () => {}; 69 private canvasRedrawer: () => void; 70 71 get canvasOverdrawFactor() { 72 return this.attrs.doesScroll ? SCROLLING_CANVAS_OVERDRAW_FACTOR : 1; 73 } 74 75 constructor(vnode: m.CVnode<Attrs>) { 76 this.attrs = vnode.attrs; 77 this.canvasRedrawer = () => this.redrawCanvas(); 78 globals.rafScheduler.addRedrawCallback(this.canvasRedrawer); 79 perfDisplay.addContainer(this); 80 } 81 82 oncreate(vnodeDom: m.CVnodeDOM<Attrs>) { 83 // Save the canvas context in the state. 84 const canvas = 85 vnodeDom.dom.querySelector('.main-canvas') as HTMLCanvasElement; 86 const ctx = canvas.getContext('2d'); 87 if (!ctx) { 88 throw Error('Cannot create canvas context'); 89 } 90 this.ctx = ctx; 91 92 const clientRect = 93 assertExists(vnodeDom.dom.parentElement).getBoundingClientRect(); 94 this.parentWidth = clientRect.width; 95 this.parentHeight = clientRect.height; 96 97 this.readPanelHeightsFromDom(vnodeDom.dom); 98 99 this.updateCanvasDimensions(); 100 this.repositionCanvas(); 101 102 // Save the resize handler in the state so we can remove it later. 103 // TODO: Encapsulate resize handling better. 104 this.onResize = () => { 105 this.readParentSizeFromDom(vnodeDom.dom); 106 this.updateCanvasDimensions(); 107 this.repositionCanvas(); 108 globals.rafScheduler.scheduleFullRedraw(); 109 }; 110 111 // Once ResizeObservers are out, we can stop accessing the window here. 112 window.addEventListener('resize', this.onResize); 113 114 // TODO(dproy): Handle change in doesScroll attribute. 115 if (this.attrs.doesScroll) { 116 this.parentOnScroll = () => { 117 this.scrollTop = assertExists(vnodeDom.dom.parentElement).scrollTop; 118 this.repositionCanvas(); 119 globals.rafScheduler.scheduleRedraw(); 120 }; 121 vnodeDom.dom.parentElement!.addEventListener( 122 'scroll', this.parentOnScroll, {passive: true}); 123 } 124 } 125 126 onremove({attrs, dom}: m.CVnodeDOM<Attrs>) { 127 window.removeEventListener('resize', this.onResize); 128 globals.rafScheduler.removeRedrawCallback(this.canvasRedrawer); 129 if (attrs.doesScroll) { 130 dom.parentElement!.removeEventListener('scroll', this.parentOnScroll); 131 } 132 perfDisplay.removeContainer(this); 133 } 134 135 view({attrs}: m.CVnode<Attrs>) { 136 this.attrs = attrs; 137 const renderPanel = (panel: m.Vnode) => perfDebug() ? 138 m('.panel', panel, m('.debug-panel-border')) : 139 m('.panel', panel); 140 141 return m( 142 '.scroll-limiter', 143 m('canvas.main-canvas'), 144 attrs.panels.map(renderPanel)); 145 } 146 147 onupdate(vnodeDom: m.CVnodeDOM<Attrs>) { 148 const totalPanelHeightChanged = this.readPanelHeightsFromDom(vnodeDom.dom); 149 const parentSizeChanged = this.readParentSizeFromDom(vnodeDom.dom); 150 151 const canvasSizeShouldChange = 152 this.attrs.doesScroll ? parentSizeChanged : totalPanelHeightChanged; 153 if (canvasSizeShouldChange) { 154 this.updateCanvasDimensions(); 155 this.repositionCanvas(); 156 } 157 } 158 159 private updateCanvasDimensions() { 160 this.canvasHeight = Math.floor( 161 this.attrs.doesScroll ? this.parentHeight * this.canvasOverdrawFactor : 162 this.totalPanelHeight); 163 const ctx = assertExists(this.ctx); 164 const canvas = assertExists(ctx.canvas); 165 canvas.style.height = `${this.canvasHeight}px`; 166 const dpr = window.devicePixelRatio; 167 ctx.canvas.width = this.parentWidth * dpr; 168 ctx.canvas.height = this.canvasHeight * dpr; 169 ctx.scale(dpr, dpr); 170 } 171 172 private repositionCanvas() { 173 const canvas = assertExists(assertExists(this.ctx).canvas); 174 const canvasYStart = 175 Math.floor(this.scrollTop - this.getCanvasOverdrawHeightPerSide()); 176 canvas.style.transform = `translateY(${canvasYStart}px)`; 177 } 178 179 /** 180 * Reads dimensions of parent node. Returns true if read dimensions are 181 * different from what was cached in the state. 182 */ 183 private readParentSizeFromDom(dom: Element): boolean { 184 const oldWidth = this.parentWidth; 185 const oldHeight = this.parentHeight; 186 const clientRect = assertExists(dom.parentElement).getBoundingClientRect(); 187 this.parentWidth = clientRect.width; 188 this.parentHeight = clientRect.height; 189 return this.parentHeight !== oldHeight || this.parentWidth !== oldWidth; 190 } 191 192 /** 193 * Reads dimensions of panels. Returns true if total panel height is different 194 * from what was cached in state. 195 */ 196 private readPanelHeightsFromDom(dom: Element): boolean { 197 const prevHeight = this.totalPanelHeight; 198 this.panelHeights = []; 199 this.totalPanelHeight = 0; 200 201 const panels = dom.querySelectorAll('.panel'); 202 assertTrue(panels.length === this.attrs.panels.length); 203 for (let i = 0; i < panels.length; i++) { 204 const height = panels[i].getBoundingClientRect().height; 205 this.panelHeights[i] = height; 206 this.totalPanelHeight += height; 207 } 208 209 return this.totalPanelHeight !== prevHeight; 210 } 211 212 private overlapsCanvas(yStart: number, yEnd: number) { 213 return yEnd > 0 && yStart < this.canvasHeight; 214 } 215 216 private redrawCanvas() { 217 const redrawStart = debugNow(); 218 if (!this.ctx) return; 219 this.ctx.clearRect(0, 0, this.parentWidth, this.canvasHeight); 220 const canvasYStart = 221 Math.floor(this.scrollTop - this.getCanvasOverdrawHeightPerSide()); 222 223 let panelYStart = 0; 224 const panels = assertExists(this.attrs).panels; 225 assertTrue(panels.length === this.panelHeights.length); 226 let totalOnCanvas = 0; 227 for (let i = 0; i < panels.length; i++) { 228 const panel = panels[i]; 229 const panelHeight = this.panelHeights[i]; 230 const yStartOnCanvas = panelYStart - canvasYStart; 231 232 if (!this.overlapsCanvas(yStartOnCanvas, yStartOnCanvas + panelHeight)) { 233 panelYStart += panelHeight; 234 continue; 235 } 236 237 totalOnCanvas++; 238 239 if (!isPanelVNode(panel)) { 240 throw Error('Vnode passed to panel container is not a panel'); 241 } 242 243 this.ctx.save(); 244 this.ctx.translate(0, yStartOnCanvas); 245 const clipRect = new Path2D(); 246 const size = {width: this.parentWidth, height: panelHeight}; 247 clipRect.rect(0, 0, size.width, size.height); 248 this.ctx.clip(clipRect); 249 const beforeRender = debugNow(); 250 panel.state.renderCanvas(this.ctx, size, panel); 251 this.updatePanelStats( 252 i, panel.state, debugNow() - beforeRender, this.ctx, size); 253 this.ctx.restore(); 254 panelYStart += panelHeight; 255 } 256 const redrawDur = debugNow() - redrawStart; 257 this.updatePerfStats(redrawDur, panels.length, totalOnCanvas); 258 } 259 260 private updatePanelStats( 261 panelIndex: number, panel: Panel, renderTime: number, 262 ctx: CanvasRenderingContext2D, size: PanelSize) { 263 if (!perfDebug()) return; 264 let renderStats = this.panelPerfStats.get(panel); 265 if (renderStats === undefined) { 266 renderStats = new RunningStatistics(); 267 this.panelPerfStats.set(panel, renderStats); 268 } 269 renderStats.addValue(renderTime); 270 271 const statW = 300; 272 ctx.fillStyle = 'hsl(97, 100%, 96%)'; 273 ctx.fillRect(size.width - statW, size.height - 20, statW, 20); 274 ctx.fillStyle = 'hsla(122, 77%, 22%)'; 275 const statStr = `Panel ${panelIndex + 1} | ` + runningStatStr(renderStats); 276 ctx.fillText(statStr, size.width - statW, size.height - 10); 277 } 278 279 private updatePerfStats( 280 renderTime: number, totalPanels: number, panelsOnCanvas: number) { 281 if (!perfDebug()) return; 282 this.perfStats.renderStats.addValue(renderTime); 283 this.perfStats.totalPanels = totalPanels; 284 this.perfStats.panelsOnCanvas = panelsOnCanvas; 285 } 286 287 renderPerfStats(index: number) { 288 assertTrue(perfDebug()); 289 return [m( 290 'section', 291 m('div', `Panel Container ${index + 1}`), 292 m('div', 293 `${this.perfStats.totalPanels} panels, ` + 294 `${this.perfStats.panelsOnCanvas} on canvas.`), 295 m('div', runningStatStr(this.perfStats.renderStats)), )]; 296 } 297 298 private getCanvasOverdrawHeightPerSide() { 299 const overdrawHeight = (this.canvasOverdrawFactor - 1) * this.parentHeight; 300 return overdrawHeight / 2; 301 } 302} 303