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