// Copyright (C) 2018 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import m from 'mithril'; import {findRef, toHTMLElement} from '../base/dom_utils'; import {assertExists, assertFalse} from '../base/logging'; import { PerfStats, PerfStatsContainer, runningStatStr, } from '../core/perf_stats'; import {raf} from '../core/raf_scheduler'; import {SimpleResizeObserver} from '../base/resize_observer'; import {canvasClip} from '../base/canvas_utils'; import {SELECTION_STROKE_COLOR, TRACK_SHELL_WIDTH} from './css_constants'; import {Bounds2D, Size2D, VerticalBounds} from '../base/geom'; import {VirtualCanvas} from './virtual_canvas'; import {DisposableStack} from '../base/disposable_stack'; import {TimeScale} from '../base/time_scale'; import {TrackNode} from '../public/workspace'; import {HTMLAttrs} from '../widgets/common'; import {TraceImpl, TraceImplAttrs} from '../core/trace_impl'; const CANVAS_OVERDRAW_PX = 100; export interface Panel { readonly kind: 'panel'; render(): m.Children; readonly selectable: boolean; // TODO(stevegolton): Remove this - panel container should know nothing of // tracks! readonly trackNode?: TrackNode; renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D): void; getSliceVerticalBounds?(depth: number): VerticalBounds | undefined; } export interface PanelGroup { readonly kind: 'group'; readonly collapsed: boolean; readonly header?: Panel; readonly topOffsetPx: number; readonly sticky: boolean; readonly childPanels: PanelOrGroup[]; } export type PanelOrGroup = Panel | PanelGroup; export interface PanelContainerAttrs extends TraceImplAttrs { panels: PanelOrGroup[]; className?: string; selectedYRange: VerticalBounds | undefined; onPanelStackResize?: (width: number, height: number) => void; // Called after all panels have been rendered to the canvas, to give the // caller the opportunity to render an overlay on top of the panels. renderOverlay?( ctx: CanvasRenderingContext2D, size: Size2D, panels: ReadonlyArray, ): void; // Called before the panels are rendered renderUnderlay?(ctx: CanvasRenderingContext2D, size: Size2D): void; } interface PanelInfo { trackNode?: TrackNode; // Can be undefined for singleton panels. panel: Panel; height: number; width: number; clientX: number; clientY: number; absY: number; } export interface RenderedPanelInfo { panel: Panel; rect: Bounds2D; } export class PanelContainer implements m.ClassComponent, PerfStatsContainer { private readonly trace: TraceImpl; private attrs: PanelContainerAttrs; // Updated every render cycle in the view() hook private panelById = new Map(); // Updated every render cycle in the oncreate/onupdate hook private panelInfos: PanelInfo[] = []; private perfStatsEnabled = false; private panelPerfStats = new WeakMap(); private perfStats = { totalPanels: 0, panelsOnCanvas: 0, renderStats: new PerfStats(10), }; private ctx?: CanvasRenderingContext2D; private readonly trash = new DisposableStack(); private readonly OVERLAY_REF = 'overlay'; private readonly PANEL_STACK_REF = 'panel-stack'; constructor({attrs}: m.CVnode) { this.attrs = attrs; this.trace = attrs.trace; this.trash.use(raf.addCanvasRedrawCallback(() => this.renderCanvas())); this.trash.use(attrs.trace.perfDebugging.addContainer(this)); } getPanelsInRegion( startX: number, endX: number, startY: number, endY: number, ): Panel[] { const minX = Math.min(startX, endX); const maxX = Math.max(startX, endX); const minY = Math.min(startY, endY); const maxY = Math.max(startY, endY); const panels: Panel[] = []; for (let i = 0; i < this.panelInfos.length; i++) { const pos = this.panelInfos[i]; const realPosX = pos.clientX - TRACK_SHELL_WIDTH; if ( realPosX + pos.width >= minX && realPosX <= maxX && pos.absY + pos.height >= minY && pos.absY <= maxY && pos.panel.selectable ) { panels.push(pos.panel); } } return panels; } // This finds the tracks covered by the in-progress area selection. When // editing areaY is not set, so this will not be used. handleAreaSelection() { const {selectedYRange} = this.attrs; const area = this.trace.timeline.selectedArea; if ( area === undefined || selectedYRange === undefined || this.panelInfos.length === 0 ) { return; } // TODO(stevegolton): We shouldn't know anything about visible time scale // right now, that's a job for our parent, but we can put one together so we // don't have to refactor this entire bit right now... const visibleTimeScale = new TimeScale(this.trace.timeline.visibleWindow, { left: 0, right: this.virtualCanvas!.size.width - TRACK_SHELL_WIDTH, }); // The Y value is given from the top of the pan and zoom region, we want it // from the top of the panel container. The parent offset corrects that. const panels = this.getPanelsInRegion( visibleTimeScale.timeToPx(area.start), visibleTimeScale.timeToPx(area.end), selectedYRange.top, selectedYRange.bottom, ); // Get the track ids from the panels. const trackUris: string[] = []; for (const panel of panels) { if (panel.trackNode) { if (panel.trackNode.isSummary) { const groupNode = panel.trackNode; // Select a track group and all child tracks if it is collapsed if (groupNode.collapsed) { for (const track of groupNode.flatTracks) { track.uri && trackUris.push(track.uri); } } } else { panel.trackNode.uri && trackUris.push(panel.trackNode.uri); } } } this.trace.timeline.selectArea(area.start, area.end, trackUris); } private virtualCanvas?: VirtualCanvas; oncreate(vnode: m.CVnodeDOM) { const {dom, attrs} = vnode; const overlayElement = toHTMLElement( assertExists(findRef(dom, this.OVERLAY_REF)), ); const virtualCanvas = new VirtualCanvas(overlayElement, dom, { overdrawPx: CANVAS_OVERDRAW_PX, }); this.trash.use(virtualCanvas); this.virtualCanvas = virtualCanvas; const ctx = virtualCanvas.canvasElement.getContext('2d'); if (!ctx) { throw Error('Cannot create canvas context'); } this.ctx = ctx; virtualCanvas.setCanvasResizeListener((canvas, width, height) => { const dpr = window.devicePixelRatio; canvas.width = width * dpr; canvas.height = height * dpr; }); virtualCanvas.setLayoutShiftListener(() => { this.renderCanvas(); }); this.onupdate(vnode); const panelStackElement = toHTMLElement( assertExists(findRef(dom, this.PANEL_STACK_REF)), ); // Listen for when the panel stack changes size this.trash.use( new SimpleResizeObserver(panelStackElement, () => { attrs.onPanelStackResize?.( panelStackElement.clientWidth, panelStackElement.clientHeight, ); }), ); } onremove() { this.trash.dispose(); } renderPanel(node: Panel, panelId: string, htmlAttrs?: HTMLAttrs): m.Vnode { assertFalse(this.panelById.has(panelId)); this.panelById.set(panelId, node); return m( `.pf-panel`, {...htmlAttrs, 'data-panel-id': panelId}, node.render(), ); } // Render a tree of panels into one vnode. Argument `path` is used to build // `key` attribute for intermediate tree vnodes: otherwise Mithril internals // will complain about keyed and non-keyed vnodes mixed together. renderTree(node: PanelOrGroup, panelId: string): m.Vnode { if (node.kind === 'group') { const style = { position: 'sticky', top: `${node.topOffsetPx}px`, zIndex: `${2000 - node.topOffsetPx}`, }; return m( 'div.pf-panel-group', node.header && this.renderPanel(node.header, `${panelId}-header`, { style: !node.collapsed && node.sticky ? style : {}, }), ...node.childPanels.map((child, index) => this.renderTree(child, `${panelId}-${index}`), ), ); } return this.renderPanel(node, panelId); } view({attrs}: m.CVnode) { this.attrs = attrs; this.panelById.clear(); const children = attrs.panels.map((panel, index) => this.renderTree(panel, `${index}`), ); return m( '.pf-panel-container', {className: attrs.className}, m( '.pf-panel-stack', {ref: this.PANEL_STACK_REF}, m('.pf-overlay', {ref: this.OVERLAY_REF}), children, ), ); } onupdate({dom}: m.CVnodeDOM) { this.readPanelRectsFromDom(dom); } private readPanelRectsFromDom(dom: Element): void { this.panelInfos = []; const panel = dom.querySelectorAll('.pf-panel'); const panels = assertExists(findRef(dom, this.PANEL_STACK_REF)); const {top} = panels.getBoundingClientRect(); panel.forEach((panelElement) => { const panelHTMLElement = toHTMLElement(panelElement); const panelId = assertExists(panelHTMLElement.dataset.panelId); const panel = assertExists(this.panelById.get(panelId)); // NOTE: the id can be undefined for singletons like overview timeline. const rect = panelElement.getBoundingClientRect(); this.panelInfos.push({ trackNode: panel.trackNode, height: rect.height, width: rect.width, clientX: rect.x, clientY: rect.y, absY: rect.y - top, panel, }); }); } private renderCanvas() { if (!this.ctx) return; if (!this.virtualCanvas) return; const ctx = this.ctx; const vc = this.virtualCanvas; const redrawStart = performance.now(); ctx.resetTransform(); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); const dpr = window.devicePixelRatio; ctx.scale(dpr, dpr); ctx.translate(-vc.canvasRect.left, -vc.canvasRect.top); this.handleAreaSelection(); const totalRenderedPanels = this.renderPanels(ctx, vc); this.drawTopLayerOnCanvas(ctx, vc); // Collect performance as the last thing we do. const redrawDur = performance.now() - redrawStart; this.updatePerfStats( redrawDur, this.panelInfos.length, totalRenderedPanels, ); } private renderPanels( ctx: CanvasRenderingContext2D, vc: VirtualCanvas, ): number { this.attrs.renderUnderlay?.(ctx, vc.size); let panelTop = 0; let totalOnCanvas = 0; const renderedPanels = Array(); for (let i = 0; i < this.panelInfos.length; i++) { const { panel, width: panelWidth, height: panelHeight, } = this.panelInfos[i]; const panelRect = { left: 0, top: panelTop, bottom: panelTop + panelHeight, right: panelWidth, }; const panelSize = {width: panelWidth, height: panelHeight}; if (vc.overlapsCanvas(panelRect)) { totalOnCanvas++; ctx.save(); ctx.translate(0, panelTop); canvasClip(ctx, 0, 0, panelWidth, panelHeight); const beforeRender = performance.now(); panel.renderCanvas(ctx, panelSize); this.updatePanelStats( i, panel, performance.now() - beforeRender, ctx, panelSize, ); ctx.restore(); } renderedPanels.push({ panel, rect: { top: panelTop, bottom: panelTop + panelHeight, left: 0, right: panelWidth, }, }); panelTop += panelHeight; } this.attrs.renderOverlay?.(ctx, vc.size, renderedPanels); return totalOnCanvas; } // The panels each draw on the canvas but some details need to be drawn across // the whole canvas rather than per panel. private drawTopLayerOnCanvas( ctx: CanvasRenderingContext2D, vc: VirtualCanvas, ): void { const {selectedYRange} = this.attrs; const area = this.trace.timeline.selectedArea; if (area === undefined || selectedYRange === undefined) { return; } if (this.panelInfos.length === 0 || area.trackUris.length === 0) { return; } // Find the minY and maxY of the selected tracks in this panel container. let selectedTracksMinY = selectedYRange.top; let selectedTracksMaxY = selectedYRange.bottom; for (let i = 0; i < this.panelInfos.length; i++) { const trackUri = this.panelInfos[i].trackNode?.uri; if (trackUri && area.trackUris.includes(trackUri)) { selectedTracksMinY = Math.min( selectedTracksMinY, this.panelInfos[i].absY, ); selectedTracksMaxY = Math.max( selectedTracksMaxY, this.panelInfos[i].absY + this.panelInfos[i].height, ); } } // TODO(stevegolton): We shouldn't know anything about visible time scale // right now, that's a job for our parent, but we can put one together so we // don't have to refactor this entire bit right now... const visibleTimeScale = new TimeScale(this.trace.timeline.visibleWindow, { left: 0, right: vc.size.width - TRACK_SHELL_WIDTH, }); const startX = visibleTimeScale.timeToPx(area.start); const endX = visibleTimeScale.timeToPx(area.end); ctx.save(); ctx.strokeStyle = SELECTION_STROKE_COLOR; ctx.lineWidth = 1; ctx.translate(TRACK_SHELL_WIDTH, 0); // Clip off any drawing happening outside the bounds of the timeline area canvasClip(ctx, 0, 0, vc.size.width - TRACK_SHELL_WIDTH, vc.size.height); ctx.strokeRect( startX, selectedTracksMaxY, endX - startX, selectedTracksMinY - selectedTracksMaxY, ); ctx.restore(); } private updatePanelStats( panelIndex: number, panel: Panel, renderTime: number, ctx: CanvasRenderingContext2D, size: Size2D, ) { if (!this.perfStatsEnabled) return; let renderStats = this.panelPerfStats.get(panel); if (renderStats === undefined) { renderStats = new PerfStats(); this.panelPerfStats.set(panel, renderStats); } renderStats.addValue(renderTime); // Draw a green box around the whole panel ctx.strokeStyle = 'rgba(69, 187, 73, 0.5)'; const lineWidth = 1; ctx.lineWidth = lineWidth; ctx.strokeRect( lineWidth / 2, lineWidth / 2, size.width - lineWidth, size.height - lineWidth, ); const statW = 300; ctx.fillStyle = 'hsl(97, 100%, 96%)'; ctx.fillRect(size.width - statW, size.height - 20, statW, 20); ctx.fillStyle = 'hsla(122, 77%, 22%)'; const statStr = `Panel ${panelIndex + 1} | ` + runningStatStr(renderStats); ctx.fillText(statStr, size.width - statW, size.height - 10); } private updatePerfStats( renderTime: number, totalPanels: number, panelsOnCanvas: number, ) { if (!this.perfStatsEnabled) return; this.perfStats.renderStats.addValue(renderTime); this.perfStats.totalPanels = totalPanels; this.perfStats.panelsOnCanvas = panelsOnCanvas; } setPerfStatsEnabled(enable: boolean): void { this.perfStatsEnabled = enable; } renderPerfStats() { return [ m( 'div', `${this.perfStats.totalPanels} panels, ` + `${this.perfStats.panelsOnCanvas} on canvas.`, ), m('div', runningStatStr(this.perfStats.renderStats)), ]; } }