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