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