| // 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<RenderedPanelInfo>, |
| ): 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<PanelContainerAttrs>, PerfStatsContainer |
| { |
| private readonly trace: TraceImpl; |
| private attrs: PanelContainerAttrs; |
| |
| // Updated every render cycle in the view() hook |
| private panelById = new Map<string, Panel>(); |
| |
| // Updated every render cycle in the oncreate/onupdate hook |
| private panelInfos: PanelInfo[] = []; |
| |
| private perfStatsEnabled = false; |
| private panelPerfStats = new WeakMap<Panel, PerfStats>(); |
| 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<PanelContainerAttrs>) { |
| 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<PanelContainerAttrs>) { |
| 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<PanelContainerAttrs>) { |
| 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<PanelContainerAttrs>) { |
| 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<RenderedPanelInfo>(); |
| |
| 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)), |
| ]; |
| } |
| } |