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