// Copyright (C) 2020 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use size 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 {time} from '../base/time'; import {exists} from '../base/utils'; import {TrackState} from '../common/state'; import {SliceRect} from '../public'; import {TRACK_SHELL_WIDTH} from './css_constants'; import {ALL_CATEGORIES, getFlowCategories} from './flow_events_panel'; import {Flow, FlowPoint, globals} from './globals'; import {Panel} from './panel_container'; const TRACK_GROUP_CONNECTION_OFFSET = 5; const TRIANGLE_SIZE = 5; const CIRCLE_RADIUS = 3; const BEZIER_OFFSET = 30; const CONNECTED_FLOW_HUE = 10; const SELECTED_FLOW_HUE = 230; const DEFAULT_FLOW_WIDTH = 2; const FOCUSED_FLOW_WIDTH = 3; const HIGHLIGHTED_FLOW_INTENSITY = 45; const FOCUSED_FLOW_INTENSITY = 55; const DEFAULT_FLOW_INTENSITY = 70; type LineDirection = 'LEFT' | 'RIGHT' | 'UP' | 'DOWN'; type ConnectionType = 'TRACK' | 'TRACK_GROUP'; interface TrackPanelInfo { panel: Panel; yStart: number; } interface TrackGroupPanelInfo { panel: Panel; yStart: number; height: number; } function getTrackIds(track: TrackState): number[] { const trackDesc = globals.trackManager.resolveTrackInfo(track.uri); return trackDesc?.trackIds ?? []; } export class FlowEventsRendererArgs { trackIdToTrackPanel: Map; groupIdToTrackGroupPanel: Map; constructor(public canvasWidth: number, public canvasHeight: number) { this.trackIdToTrackPanel = new Map(); this.groupIdToTrackGroupPanel = new Map(); } registerPanel(panel: Panel, yStart: number, height: number) { if (exists(panel.trackKey)) { const track = globals.state.tracks[panel.trackKey]; for (const trackId of getTrackIds(track)) { this.trackIdToTrackPanel.set(trackId, {panel, yStart}); } } else if (exists(panel.groupKey)) { this.groupIdToTrackGroupPanel.set(panel.groupKey, { panel, yStart, height, }); } } } export class FlowEventsRenderer { private getTrackGroupIdByTrackId(trackId: number): string | undefined { const trackKey = globals.trackManager.trackKeyByTrackId.get(trackId); return trackKey ? globals.state.tracks[trackKey].trackGroup : undefined; } private getTrackGroupYCoordinate( args: FlowEventsRendererArgs, trackId: number, ): number | undefined { const trackGroupId = this.getTrackGroupIdByTrackId(trackId); if (!trackGroupId) { return undefined; } const trackGroupInfo = args.groupIdToTrackGroupPanel.get(trackGroupId); if (!trackGroupInfo) { return undefined; } return ( trackGroupInfo.yStart + trackGroupInfo.height - TRACK_GROUP_CONNECTION_OFFSET ); } private getTrackYCoordinate( args: FlowEventsRendererArgs, trackId: number, ): number | undefined { return args.trackIdToTrackPanel.get(trackId)?.yStart; } private getYConnection( args: FlowEventsRendererArgs, trackId: number, rect?: SliceRect, ): {y: number; connection: ConnectionType} | undefined { if (!rect) { const y = this.getTrackGroupYCoordinate(args, trackId); if (y === undefined) { return undefined; } return {y, connection: 'TRACK_GROUP'}; } const y = (this.getTrackYCoordinate(args, trackId) ?? 0) + rect.top + rect.height * 0.5; return { y: Math.min(Math.max(0, y), args.canvasHeight), connection: 'TRACK', }; } private getXCoordinate(ts: time): number { return globals.timeline.visibleTimeScale.timeToPx(ts); } private getSliceRect( args: FlowEventsRendererArgs, point: FlowPoint, ): SliceRect | undefined { const trackPanel = args.trackIdToTrackPanel.get(point.trackId)?.panel; if (!trackPanel) { return undefined; } return trackPanel.getSliceRect?.( point.sliceStartTs, point.sliceEndTs, point.depth, ); } render(ctx: CanvasRenderingContext2D, args: FlowEventsRendererArgs) { ctx.save(); ctx.translate(TRACK_SHELL_WIDTH, 0); ctx.rect(0, 0, args.canvasWidth - TRACK_SHELL_WIDTH, args.canvasHeight); ctx.clip(); globals.connectedFlows.forEach((flow) => { this.drawFlow(ctx, args, flow, CONNECTED_FLOW_HUE); }); globals.selectedFlows.forEach((flow) => { const categories = getFlowCategories(flow); for (const cat of categories) { if ( globals.visibleFlowCategories.get(cat) || globals.visibleFlowCategories.get(ALL_CATEGORIES) ) { this.drawFlow(ctx, args, flow, SELECTED_FLOW_HUE); break; } } }); ctx.restore(); } private drawFlow( ctx: CanvasRenderingContext2D, args: FlowEventsRendererArgs, flow: Flow, hue: number, ) { const beginSliceRect = this.getSliceRect(args, flow.begin); const endSliceRect = this.getSliceRect(args, flow.end); const beginYConnection = this.getYConnection( args, flow.begin.trackId, beginSliceRect, ); const endYConnection = this.getYConnection( args, flow.end.trackId, endSliceRect, ); if (!beginYConnection || !endYConnection) { return; } let beginDir: LineDirection = 'LEFT'; let endDir: LineDirection = 'RIGHT'; if (beginYConnection.connection === 'TRACK_GROUP') { beginDir = beginYConnection.y > endYConnection.y ? 'DOWN' : 'UP'; } if (endYConnection.connection === 'TRACK_GROUP') { endDir = endYConnection.y > beginYConnection.y ? 'DOWN' : 'UP'; } const begin = { // If the flow goes to a descendant, we want to draw the arrow from the // beginning of the slice // rather from the end to avoid the flow arrow going backwards. x: this.getXCoordinate( flow.flowToDescendant || flow.begin.sliceStartTs >= flow.end.sliceStartTs ? flow.begin.sliceStartTs : flow.begin.sliceEndTs, ), y: beginYConnection.y, dir: beginDir, }; const end = { x: this.getXCoordinate(flow.end.sliceStartTs), y: endYConnection.y, dir: endDir, }; const highlighted = flow.end.sliceId === globals.state.highlightedSliceId || flow.begin.sliceId === globals.state.highlightedSliceId; const focused = flow.id === globals.state.focusedFlowIdLeft || flow.id === globals.state.focusedFlowIdRight; let intensity = DEFAULT_FLOW_INTENSITY; let width = DEFAULT_FLOW_WIDTH; if (focused) { intensity = FOCUSED_FLOW_INTENSITY; width = FOCUSED_FLOW_WIDTH; } if (highlighted) { intensity = HIGHLIGHTED_FLOW_INTENSITY; } this.drawFlowArrow(ctx, begin, end, hue, intensity, width); } private getDeltaX(dir: LineDirection, offset: number): number { switch (dir) { case 'LEFT': return -offset; case 'RIGHT': return offset; case 'UP': return 0; case 'DOWN': return 0; default: return 0; } } private getDeltaY(dir: LineDirection, offset: number): number { switch (dir) { case 'LEFT': return 0; case 'RIGHT': return 0; case 'UP': return -offset; case 'DOWN': return offset; default: return 0; } } private drawFlowArrow( ctx: CanvasRenderingContext2D, begin: {x: number; y: number; dir: LineDirection}, end: {x: number; y: number; dir: LineDirection}, hue: number, intensity: number, width: number, ) { const hasArrowHead = Math.abs(begin.x - end.x) > 3 * TRIANGLE_SIZE; const END_OFFSET = (end.dir === 'RIGHT' || end.dir === 'LEFT') && hasArrowHead ? TRIANGLE_SIZE : 0; const color = `hsl(${hue}, 50%, ${intensity}%)`; // draw curved line from begin to end (bezier curve) ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath(); ctx.moveTo(begin.x, begin.y); ctx.bezierCurveTo( begin.x - this.getDeltaX(begin.dir, BEZIER_OFFSET), begin.y - this.getDeltaY(begin.dir, BEZIER_OFFSET), end.x - this.getDeltaX(end.dir, BEZIER_OFFSET + END_OFFSET), end.y - this.getDeltaY(end.dir, BEZIER_OFFSET + END_OFFSET), end.x - this.getDeltaX(end.dir, END_OFFSET), end.y - this.getDeltaY(end.dir, END_OFFSET), ); ctx.stroke(); // TODO (andrewbb): probably we should add a parameter 'MarkerType' to be // able to choose what marker we want to draw _before_ the function call. // e.g. triangle, circle, square? if (begin.dir !== 'RIGHT' && begin.dir !== 'LEFT') { // draw a circle if we the line has a vertical connection ctx.fillStyle = color; ctx.beginPath(); ctx.arc(begin.x, begin.y, 3, 0, 2 * Math.PI); ctx.closePath(); ctx.fill(); } if (end.dir !== 'RIGHT' && end.dir !== 'LEFT') { // draw a circle if we the line has a vertical connection ctx.fillStyle = color; ctx.beginPath(); ctx.arc(end.x, end.y, CIRCLE_RADIUS, 0, 2 * Math.PI); ctx.closePath(); ctx.fill(); } else if (hasArrowHead) { this.drawArrowHead(end, ctx, color); } } private drawArrowHead( end: {x: number; y: number; dir: LineDirection}, ctx: CanvasRenderingContext2D, color: string, ) { const dx = this.getDeltaX(end.dir, TRIANGLE_SIZE); const dy = this.getDeltaY(end.dir, TRIANGLE_SIZE); // draw small triangle ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(end.x, end.y); ctx.lineTo(end.x - dx - dy, end.y + dx - dy); ctx.lineTo(end.x - dx + dy, end.y - dx - dy); ctx.closePath(); ctx.fill(); } }