| // 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 {Trash} from '../base/disposable'; |
| import {findRef, getScrollbarWidth} from '../base/dom_utils'; |
| import {assertExists, assertFalse} from '../base/logging'; |
| import {SimpleResizeObserver} from '../base/resize_observer'; |
| import {time} from '../base/time'; |
| import { |
| debugNow, |
| perfDebug, |
| perfDisplay, |
| PerfStatsSource, |
| RunningStatistics, |
| runningStatStr, |
| } from '../core/perf'; |
| import {raf} from '../core/raf_scheduler'; |
| import {SliceRect} from '../public'; |
| |
| import { |
| SELECTION_STROKE_COLOR, |
| TOPBAR_HEIGHT, |
| TRACK_SHELL_WIDTH, |
| } from './css_constants'; |
| import { |
| FlowEventsRenderer, |
| FlowEventsRendererArgs, |
| } from './flow_events_renderer'; |
| import {globals} from './globals'; |
| import {PanelSize} from './panel'; |
| import {canvasClip} from '../common/canvas_utils'; |
| |
| // If the panel container scrolls, the backing canvas height is |
| // SCROLLING_CANVAS_OVERDRAW_FACTOR * parent container height. |
| const SCROLLING_CANVAS_OVERDRAW_FACTOR = 1.2; |
| |
| export interface Panel { |
| kind: 'panel'; |
| render(): m.Children; |
| selectable: boolean; |
| key: string; |
| trackKey?: string; |
| trackGroupId?: string; |
| renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize): void; |
| getSliceRect?(tStart: time, tDur: time, depth: number): SliceRect | undefined; |
| } |
| |
| export interface PanelGroup { |
| kind: 'group'; |
| collapsed: boolean; |
| header: Panel; |
| childTracks: Panel[]; |
| trackGroupId: string; |
| } |
| |
| export type PanelOrGroup = Panel | PanelGroup; |
| |
| export interface PanelContainerAttrs { |
| panels: PanelOrGroup[]; |
| doesScroll: boolean; |
| kind: 'TRACKS' | 'OVERVIEW'; |
| className?: string; |
| } |
| |
| interface PanelInfo { |
| id: string; // Can be == '' for singleton panels. |
| panel: Panel; |
| height: number; |
| width: number; |
| x: number; |
| y: number; |
| } |
| |
| export class PanelContainer |
| implements m.ClassComponent<PanelContainerAttrs>, PerfStatsSource |
| { |
| // These values are updated with proper values in oncreate. |
| private parentWidth = 0; |
| private parentHeight = 0; |
| private scrollTop = 0; |
| private panelInfos: PanelInfo[] = []; |
| private panelContainerTop = 0; |
| private panelContainerHeight = 0; |
| private panelByKey = new Map<string, Panel>(); |
| private totalPanelHeight = 0; |
| private canvasHeight = 0; |
| |
| private flowEventsRenderer: FlowEventsRenderer; |
| |
| private panelPerfStats = new WeakMap<Panel, RunningStatistics>(); |
| private perfStats = { |
| totalPanels: 0, |
| panelsOnCanvas: 0, |
| renderStats: new RunningStatistics(10), |
| }; |
| |
| // Attrs received in the most recent mithril redraw. We receive a new vnode |
| // with new attrs on every redraw, and we cache it here so that resize |
| // listeners and canvas redraw callbacks can access it. |
| private attrs: PanelContainerAttrs; |
| |
| private ctx?: CanvasRenderingContext2D; |
| |
| private trash: Trash; |
| |
| private readonly SCROLL_LIMITER_REF = 'scroll-limiter'; |
| private readonly PANELS_REF = 'panels'; |
| private readonly OVERLAY_CANVAS_REF = 'canvas'; |
| |
| get canvasOverdrawFactor() { |
| return this.attrs.doesScroll ? SCROLLING_CANVAS_OVERDRAW_FACTOR : 1; |
| } |
| |
| 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.x - TRACK_SHELL_WIDTH; |
| if ( |
| realPosX + pos.width >= minX && |
| realPosX <= maxX && |
| pos.y + pos.height >= minY && |
| pos.y <= 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 area = globals.timeline.selectedArea; |
| if ( |
| area === undefined || |
| globals.timeline.areaY.start === undefined || |
| globals.timeline.areaY.end === undefined || |
| this.panelInfos.length === 0 |
| ) { |
| return; |
| } |
| // Only get panels from the current panel container if the selection began |
| // in this container. |
| const panelContainerTop = this.panelInfos[0].y; |
| const panelContainerBottom = |
| this.panelInfos[this.panelInfos.length - 1].y + |
| this.panelInfos[this.panelInfos.length - 1].height; |
| if ( |
| globals.timeline.areaY.start + TOPBAR_HEIGHT < panelContainerTop || |
| globals.timeline.areaY.start + TOPBAR_HEIGHT > panelContainerBottom |
| ) { |
| return; |
| } |
| |
| const {visibleTimeScale} = globals.timeline; |
| |
| // 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), |
| globals.timeline.areaY.start + TOPBAR_HEIGHT, |
| globals.timeline.areaY.end + TOPBAR_HEIGHT, |
| ); |
| // Get the track ids from the panels. |
| const tracks = []; |
| for (const panel of panels) { |
| if (panel.trackKey !== undefined) { |
| tracks.push(panel.trackKey); |
| continue; |
| } |
| if (panel.trackGroupId !== undefined) { |
| const trackGroup = globals.state.trackGroups[panel.trackGroupId]; |
| // Only select a track group and all child tracks if it is closed. |
| if (trackGroup.collapsed) { |
| tracks.push(panel.trackGroupId); |
| for (const track of trackGroup.tracks) { |
| tracks.push(track); |
| } |
| } |
| } |
| } |
| globals.timeline.selectArea(area.start, area.end, tracks); |
| } |
| |
| constructor(vnode: m.CVnode<PanelContainerAttrs>) { |
| this.attrs = vnode.attrs; |
| this.flowEventsRenderer = new FlowEventsRenderer(); |
| this.trash = new Trash(); |
| |
| const onRedraw = () => this.renderCanvas(); |
| raf.addRedrawCallback(onRedraw); |
| this.trash.addCallback(() => { |
| raf.removeRedrawCallback(onRedraw); |
| }); |
| |
| perfDisplay.addContainer(this); |
| this.trash.addCallback(() => { |
| perfDisplay.removeContainer(this); |
| }); |
| } |
| |
| oncreate({dom}: m.CVnodeDOM<PanelContainerAttrs>) { |
| // Save the canvas context in the state. |
| const canvas = findRef(dom, this.OVERLAY_CANVAS_REF) as HTMLCanvasElement; |
| const ctx = canvas.getContext('2d'); |
| if (!ctx) { |
| throw Error('Cannot create canvas context'); |
| } |
| this.ctx = ctx; |
| |
| this.readParentSizeFromDom(dom); |
| this.readPanelHeightsFromDom(dom); |
| |
| this.updateCanvasDimensions(); |
| this.repositionCanvas(); |
| |
| const scrollLimiter = assertExists(findRef(dom, this.SCROLL_LIMITER_REF)); |
| this.trash.add( |
| new SimpleResizeObserver(scrollLimiter, () => { |
| const parentSizeChanged = this.readParentSizeFromDom(dom); |
| if (parentSizeChanged) { |
| this.updateCanvasDimensions(); |
| this.repositionCanvas(); |
| this.renderCanvas(); |
| } |
| }), |
| ); |
| |
| // TODO(dproy): Handle change in doesScroll attribute. |
| if (this.attrs.doesScroll) { |
| const parentOnScroll = () => { |
| this.scrollTop = dom.scrollTop; |
| this.repositionCanvas(); |
| raf.scheduleRedraw(); |
| }; |
| dom.addEventListener('scroll', parentOnScroll, {passive: true}); |
| this.trash.addCallback(() => { |
| dom.removeEventListener('scroll', parentOnScroll); |
| }); |
| } |
| } |
| |
| onremove() { |
| this.trash.dispose(); |
| } |
| |
| renderPanel(node: Panel, key: string, extraClass = ''): m.Vnode { |
| assertFalse(this.panelByKey.has(key)); |
| this.panelByKey.set(key, node); |
| return m(`.pf-panel${extraClass}`, {key, 'data-key': key}, 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, path: string): m.Vnode { |
| if (node.kind === 'group') { |
| return m( |
| 'div', |
| {key: path}, |
| this.renderPanel( |
| node.header, |
| `${path}-header`, |
| node.collapsed ? '' : '.pf-sticky', |
| ), |
| ...node.childTracks.map((child, index) => |
| this.renderTree(child, `${path}-${index}`), |
| ), |
| ); |
| } |
| return this.renderPanel(node, assertExists(node.key)); |
| } |
| |
| view({attrs}: m.CVnode<PanelContainerAttrs>) { |
| this.attrs = attrs; |
| this.panelByKey.clear(); |
| const children = attrs.panels.map((panel, index) => |
| this.renderTree(panel, `track-tree-${index}`), |
| ); |
| |
| return m( |
| '.pf-panel-container', |
| {className: attrs.className}, |
| m( |
| '.pf-panels', |
| {ref: this.PANELS_REF}, |
| m( |
| '.pf-scroll-limiter', |
| {ref: this.SCROLL_LIMITER_REF}, |
| m('canvas.pf-overlay-canvas', {ref: this.OVERLAY_CANVAS_REF}), |
| ), |
| children, |
| ), |
| ); |
| } |
| |
| onupdate({dom}: m.CVnodeDOM<PanelContainerAttrs>) { |
| const totalPanelHeightChanged = this.readPanelHeightsFromDom(dom); |
| const parentSizeChanged = this.readParentSizeFromDom(dom); |
| const canvasSizeShouldChange = |
| parentSizeChanged || (!this.attrs.doesScroll && totalPanelHeightChanged); |
| if (canvasSizeShouldChange) { |
| this.updateCanvasDimensions(); |
| this.repositionCanvas(); |
| if (this.attrs.kind === 'TRACKS') { |
| globals.timeline.updateLocalLimits( |
| 0, |
| this.parentWidth - TRACK_SHELL_WIDTH, |
| ); |
| } |
| this.renderCanvas(); |
| } |
| } |
| |
| private updateCanvasDimensions() { |
| this.canvasHeight = Math.floor( |
| this.attrs.doesScroll |
| ? this.parentHeight * this.canvasOverdrawFactor |
| : this.totalPanelHeight, |
| ); |
| const ctx = assertExists(this.ctx); |
| const canvas = assertExists(ctx.canvas); |
| canvas.style.height = `${this.canvasHeight}px`; |
| |
| // If're we're non-scrolling canvas and the scroll-limiter should always |
| // have the same height. Enforce this by explicitly setting the height. |
| if (!this.attrs.doesScroll) { |
| const scrollLimiter = canvas.parentElement; |
| if (scrollLimiter) { |
| scrollLimiter.style.height = `${this.canvasHeight}px`; |
| } |
| } |
| |
| const dpr = window.devicePixelRatio; |
| ctx.canvas.width = this.parentWidth * dpr; |
| ctx.canvas.height = this.canvasHeight * dpr; |
| ctx.scale(dpr, dpr); |
| } |
| |
| private repositionCanvas() { |
| const canvas = assertExists(assertExists(this.ctx).canvas); |
| const canvasYStart = Math.floor( |
| this.scrollTop - this.getCanvasOverdrawHeightPerSide(), |
| ); |
| canvas.style.transform = `translateY(${canvasYStart}px)`; |
| } |
| |
| // Reads dimensions of parent node. Returns true if read dimensions are |
| // different from what was cached in the state. |
| private readParentSizeFromDom(dom: Element): boolean { |
| const oldWidth = this.parentWidth; |
| const oldHeight = this.parentHeight; |
| const clientRect = dom.getBoundingClientRect(); |
| // On non-MacOS if there is a solid scroll bar it can cover important |
| // pixels, reduce the size of the canvas so it doesn't overlap with |
| // the scroll bar. |
| this.parentWidth = clientRect.width - getScrollbarWidth(); |
| this.parentHeight = clientRect.height; |
| return this.parentHeight !== oldHeight || this.parentWidth !== oldWidth; |
| } |
| |
| // Reads dimensions of panels. Returns true if total panel height is different |
| // from what was cached in state. |
| private readPanelHeightsFromDom(dom: Element): boolean { |
| const prevHeight = this.totalPanelHeight; |
| this.panelInfos = []; |
| this.totalPanelHeight = 0; |
| |
| const panels = assertExists(findRef(dom, this.PANELS_REF)); |
| const domRect = panels.getBoundingClientRect(); |
| this.panelContainerTop = domRect.y; |
| this.panelContainerHeight = domRect.height; |
| |
| dom.querySelectorAll('.pf-panel').forEach((panelElement) => { |
| const key = assertExists(panelElement.getAttribute('data-key')); |
| const panel = assertExists(this.panelByKey.get(key)); |
| |
| // NOTE: the id can be undefined for singletons like overview timeline. |
| const id = panel.trackKey || panel.trackGroupId || ''; |
| const rect = panelElement.getBoundingClientRect(); |
| this.panelInfos.push({ |
| id, |
| height: rect.height, |
| width: rect.width, |
| x: rect.x, |
| y: rect.y, |
| panel, |
| }); |
| this.totalPanelHeight += rect.height; |
| }); |
| |
| return this.totalPanelHeight !== prevHeight; |
| } |
| |
| private overlapsCanvas(yStart: number, yEnd: number) { |
| return yEnd > 0 && yStart < this.canvasHeight; |
| } |
| |
| private renderCanvas() { |
| const redrawStart = debugNow(); |
| if (!this.ctx) return; |
| this.ctx.clearRect(0, 0, this.parentWidth, this.canvasHeight); |
| const canvasYStart = Math.floor( |
| this.scrollTop - this.getCanvasOverdrawHeightPerSide(), |
| ); |
| |
| this.handleAreaSelection(); |
| |
| let panelYStart = 0; |
| let totalOnCanvas = 0; |
| const flowEventsRendererArgs = new FlowEventsRendererArgs( |
| this.parentWidth, |
| this.canvasHeight, |
| ); |
| for (let i = 0; i < this.panelInfos.length; i++) { |
| const panel = this.panelInfos[i].panel; |
| const panelHeight = this.panelInfos[i].height; |
| const yStartOnCanvas = panelYStart - canvasYStart; |
| |
| flowEventsRendererArgs.registerPanel(panel, yStartOnCanvas, panelHeight); |
| |
| if (!this.overlapsCanvas(yStartOnCanvas, yStartOnCanvas + panelHeight)) { |
| panelYStart += panelHeight; |
| continue; |
| } |
| |
| totalOnCanvas++; |
| |
| this.ctx.save(); |
| this.ctx.translate(0, yStartOnCanvas); |
| const clipRect = new Path2D(); |
| const size = {width: this.parentWidth, height: panelHeight}; |
| clipRect.rect(0, 0, size.width, size.height); |
| this.ctx.clip(clipRect); |
| const beforeRender = debugNow(); |
| panel.renderCanvas(this.ctx, size); |
| this.updatePanelStats( |
| i, |
| panel, |
| debugNow() - beforeRender, |
| this.ctx, |
| size, |
| ); |
| this.ctx.restore(); |
| panelYStart += panelHeight; |
| } |
| |
| this.drawTopLayerOnCanvas(); |
| this.flowEventsRenderer.render(this.ctx, flowEventsRendererArgs); |
| // Collect performance as the last thing we do. |
| const redrawDur = debugNow() - redrawStart; |
| this.updatePerfStats(redrawDur, this.panelInfos.length, 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() { |
| if (!this.ctx) return; |
| const area = globals.timeline.selectedArea; |
| if ( |
| area === undefined || |
| globals.timeline.areaY.start === undefined || |
| globals.timeline.areaY.end === undefined |
| ) { |
| return; |
| } |
| if (this.panelInfos.length === 0 || area.tracks.length === 0) return; |
| |
| // Find the minY and maxY of the selected tracks in this panel container. |
| let selectedTracksMinY = this.panelContainerHeight + this.panelContainerTop; |
| let selectedTracksMaxY = this.panelContainerTop; |
| let trackFromCurrentContainerSelected = false; |
| for (let i = 0; i < this.panelInfos.length; i++) { |
| if (area.tracks.includes(this.panelInfos[i].id)) { |
| trackFromCurrentContainerSelected = true; |
| selectedTracksMinY = Math.min(selectedTracksMinY, this.panelInfos[i].y); |
| selectedTracksMaxY = Math.max( |
| selectedTracksMaxY, |
| this.panelInfos[i].y + this.panelInfos[i].height, |
| ); |
| } |
| } |
| |
| // No box should be drawn if there are no selected tracks in the current |
| // container. |
| if (!trackFromCurrentContainerSelected) { |
| return; |
| } |
| |
| const {visibleTimeScale} = globals.timeline; |
| const startX = visibleTimeScale.timeToPx(area.start); |
| const endX = visibleTimeScale.timeToPx(area.end); |
| // To align with where to draw on the canvas subtract the first panel Y. |
| selectedTracksMinY -= this.panelContainerTop; |
| selectedTracksMaxY -= this.panelContainerTop; |
| this.ctx.save(); |
| this.ctx.strokeStyle = SELECTION_STROKE_COLOR; |
| this.ctx.lineWidth = 1; |
| const canvasYStart = Math.floor( |
| this.scrollTop - this.getCanvasOverdrawHeightPerSide(), |
| ); |
| this.ctx.translate(TRACK_SHELL_WIDTH, -canvasYStart); |
| |
| // Clip off any drawing happening outside the bounds of the timeline area |
| canvasClip( |
| this.ctx, |
| 0, |
| 0, |
| this.parentWidth - TRACK_SHELL_WIDTH, |
| this.totalPanelHeight, |
| ); |
| |
| this.ctx.strokeRect( |
| startX, |
| selectedTracksMaxY, |
| endX - startX, |
| selectedTracksMinY - selectedTracksMaxY, |
| ); |
| this.ctx.restore(); |
| } |
| |
| private updatePanelStats( |
| panelIndex: number, |
| panel: Panel, |
| renderTime: number, |
| ctx: CanvasRenderingContext2D, |
| size: PanelSize, |
| ) { |
| if (!perfDebug()) return; |
| let renderStats = this.panelPerfStats.get(panel); |
| if (renderStats === undefined) { |
| renderStats = new RunningStatistics(); |
| 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 (!perfDebug()) return; |
| this.perfStats.renderStats.addValue(renderTime); |
| this.perfStats.totalPanels = totalPanels; |
| this.perfStats.panelsOnCanvas = panelsOnCanvas; |
| } |
| |
| renderPerfStats() { |
| return [ |
| m( |
| 'div', |
| `${this.perfStats.totalPanels} panels, ` + |
| `${this.perfStats.panelsOnCanvas} on canvas.`, |
| ), |
| m('div', runningStatStr(this.perfStats.renderStats)), |
| ]; |
| } |
| |
| private getCanvasOverdrawHeightPerSide() { |
| const overdrawHeight = (this.canvasOverdrawFactor - 1) * this.parentHeight; |
| return overdrawHeight / 2; |
| } |
| } |