| // 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 {searchSegment} from '../base/binary_search'; |
| import {cropText} from '../common/canvas_utils'; |
| |
| import {CallsiteInfo} from '../common/state'; |
| |
| interface Node { |
| width: number; |
| x: number; |
| nextXForChildren: number; |
| size: number; |
| } |
| |
| interface CallsiteInfoWidth { |
| callsite: CallsiteInfo; |
| width: number; |
| } |
| |
| // Height of one 'row' on the flame chart including 1px of whitespace |
| // below the box. |
| const NODE_HEIGHT = 18; |
| |
| export const FLAMEGRAPH_HOVERED_COLOR = 'hsl(224, 45%, 55%)'; |
| |
| export function findRootSize(data: CallsiteInfo[]) { |
| let totalSize = 0; |
| let i = 0; |
| while (i < data.length && data[i].depth === 0) { |
| totalSize += data[i].totalSize; |
| i++; |
| } |
| return totalSize; |
| } |
| |
| export interface NodeRendering { |
| totalSize?: string; |
| selfSize?: string; |
| } |
| |
| export class Flamegraph { |
| private nodeRendering: NodeRendering = {}; |
| private flamegraphData: CallsiteInfo[]; |
| private highlightSomeNodes = false; |
| private maxDepth = -1; |
| private totalSize = -1; |
| // Initialised on first draw() call |
| private labelCharWidth = 0; |
| private labelFontStyle = '12px Roboto Mono'; |
| private rolloverFontStyle = '12px Roboto Condensed'; |
| // Key for the map is depth followed by x coordinate - `depth;x` |
| private graphData: Map<string, CallsiteInfoWidth> = new Map(); |
| private xStartsPerDepth: Map<number, number[]> = new Map(); |
| |
| private hoveredX = -1; |
| private hoveredY = -1; |
| private hoveredCallsite?: CallsiteInfo; |
| private clickedCallsite?: CallsiteInfo; |
| |
| private startingY = 0; |
| |
| constructor(flamegraphData: CallsiteInfo[]) { |
| this.flamegraphData = flamegraphData; |
| this.findMaxDepth(); |
| } |
| |
| private findMaxDepth() { |
| this.maxDepth = Math.max( |
| ...this.flamegraphData.map((value) => value.depth), |
| ); |
| } |
| |
| // Instead of highlighting the interesting nodes, we actually want to |
| // de-emphasize the non-highlighted nodes. Returns true if there |
| // are any highlighted nodes in the flamegraph. |
| private highlightingExists() { |
| this.highlightSomeNodes = this.flamegraphData.some((e) => e.highlighted); |
| } |
| |
| generateColor( |
| name: string, |
| isGreyedOut = false, |
| highlighted: boolean, |
| ): string { |
| if (isGreyedOut) { |
| return '#d9d9d9'; |
| } |
| if (name === 'unknown' || name === 'root') { |
| return '#c0c0c0'; |
| } |
| let x = 0; |
| for (let i = 0; i < name.length; i += 1) { |
| x += name.charCodeAt(i) % 64; |
| } |
| x = x % 360; |
| let l = '76'; |
| // Make non-highlighted node lighter. |
| if (this.highlightSomeNodes && !highlighted) { |
| l = '90'; |
| } |
| return `hsl(${x}deg, 45%, ${l}%)`; |
| } |
| |
| // Caller will have to call draw method after updating data to have updated |
| // graph. |
| updateDataIfChanged( |
| nodeRendering: NodeRendering, |
| flamegraphData: CallsiteInfo[], |
| clickedCallsite?: CallsiteInfo, |
| ) { |
| this.nodeRendering = nodeRendering; |
| this.clickedCallsite = clickedCallsite; |
| if (this.flamegraphData === flamegraphData) { |
| return; |
| } |
| this.flamegraphData = flamegraphData; |
| this.clickedCallsite = clickedCallsite; |
| this.findMaxDepth(); |
| this.highlightingExists(); |
| // Finding total size of roots. |
| this.totalSize = findRootSize(flamegraphData); |
| } |
| |
| draw( |
| ctx: CanvasRenderingContext2D, |
| width: number, |
| height: number, |
| x = 0, |
| y = 0, |
| unit = 'B', |
| ) { |
| if (this.flamegraphData === undefined) { |
| return; |
| } |
| |
| ctx.font = this.labelFontStyle; |
| ctx.textBaseline = 'middle'; |
| if (this.labelCharWidth === 0) { |
| this.labelCharWidth = ctx.measureText('_').width; |
| } |
| |
| this.startingY = y; |
| |
| // For each node, we use this map to get information about its parent |
| // (total size of it, width and where it starts in graph) so we can |
| // calculate it's own position in graph. |
| const nodesMap = new Map<number, Node>(); |
| let currentY = y; |
| nodesMap.set(-1, {width, nextXForChildren: x, size: this.totalSize, x}); |
| |
| // Initialize data needed for click/hover behavior. |
| this.graphData = new Map(); |
| this.xStartsPerDepth = new Map(); |
| |
| // Draw root node. |
| ctx.fillStyle = this.generateColor('root', false, false); |
| ctx.fillRect(x, currentY, width, NODE_HEIGHT - 1); |
| const text = cropText( |
| `root: ${this.displaySize( |
| this.totalSize, |
| unit, |
| unit === 'B' ? 1024 : 1000, |
| )}`, |
| this.labelCharWidth, |
| width - 2, |
| ); |
| ctx.fillStyle = 'black'; |
| ctx.fillText(text, x + 5, currentY + (NODE_HEIGHT - 1) / 2); |
| currentY += NODE_HEIGHT; |
| |
| // Set style for borders. |
| ctx.strokeStyle = 'white'; |
| ctx.lineWidth = 0.5; |
| |
| for (let i = 0; i < this.flamegraphData.length; i++) { |
| if (currentY > height) { |
| break; |
| } |
| const value = this.flamegraphData[i]; |
| const parentNode = nodesMap.get(value.parentId); |
| if (parentNode === undefined) { |
| continue; |
| } |
| |
| const isClicked = this.clickedCallsite !== undefined; |
| const isFullWidth = |
| isClicked && value.depth <= this.clickedCallsite!.depth; |
| const isGreyedOut = |
| isClicked && value.depth < this.clickedCallsite!.depth; |
| |
| const parent = value.parentId; |
| const parentSize = parent === -1 ? this.totalSize : parentNode.size; |
| // Calculate node's width based on its proportion in parent. |
| const width = |
| (isFullWidth ? 1 : value.totalSize / parentSize) * parentNode.width; |
| |
| const currentX = parentNode.nextXForChildren; |
| currentY = y + NODE_HEIGHT * (value.depth + 1); |
| |
| // Draw node. |
| const name = this.getCallsiteName(value); |
| ctx.fillStyle = this.generateColor(name, isGreyedOut, value.highlighted); |
| ctx.fillRect(currentX, currentY, width, NODE_HEIGHT - 1); |
| |
| // Set current node's data in map for children to use. |
| nodesMap.set(value.id, { |
| width, |
| nextXForChildren: currentX, |
| size: value.totalSize, |
| x: currentX, |
| }); |
| // Update next x coordinate in parent. |
| nodesMap.set(value.parentId, { |
| width: parentNode.width, |
| nextXForChildren: currentX + width, |
| size: parentNode.size, |
| x: parentNode.x, |
| }); |
| |
| // Draw name. |
| const labelPaddingPx = 5; |
| const maxLabelWidth = width - labelPaddingPx * 2; |
| let text = cropText(name, this.labelCharWidth, maxLabelWidth); |
| // If cropped text and the original text are within 20% we keep the |
| // original text and just squish it a bit. |
| if (text.length * 1.2 > name.length) { |
| text = name; |
| } |
| ctx.fillStyle = 'black'; |
| ctx.fillText( |
| text, |
| currentX + labelPaddingPx, |
| currentY + (NODE_HEIGHT - 1) / 2, |
| maxLabelWidth, |
| ); |
| |
| // Draw border on the right of node. |
| ctx.beginPath(); |
| ctx.moveTo(currentX + width, currentY); |
| ctx.lineTo(currentX + width, currentY + NODE_HEIGHT); |
| ctx.stroke(); |
| |
| // Add this node for recognizing in click/hover. |
| // Map graphData contains one callsite which is on that depth and X |
| // start. Map xStartsPerDepth for each depth contains all X start |
| // coordinates that callsites on that level have. |
| this.graphData.set(`${value.depth};${currentX}`, { |
| callsite: value, |
| width, |
| }); |
| const xStarts = this.xStartsPerDepth.get(value.depth); |
| if (xStarts === undefined) { |
| this.xStartsPerDepth.set(value.depth, [currentX]); |
| } else { |
| xStarts.push(currentX); |
| } |
| } |
| |
| // Draw the tooltip. |
| if (this.hoveredX > -1 && this.hoveredY > -1 && this.hoveredCallsite) { |
| // Must set these before measureText below. |
| ctx.font = this.rolloverFontStyle; |
| ctx.textBaseline = 'top'; |
| |
| // Size in px of the border around the text and the edge of the rollover |
| // background. |
| const paddingPx = 8; |
| // Size in px of the x and y offset between the mouse and the top left |
| // corner of the rollover box. |
| const offsetPx = 4; |
| |
| const lines: string[] = []; |
| |
| let textWidth = this.addToTooltip( |
| this.getCallsiteName(this.hoveredCallsite), |
| width - paddingPx, |
| ctx, |
| lines, |
| ); |
| if (this.hoveredCallsite.location != null) { |
| textWidth = Math.max( |
| textWidth, |
| this.addToTooltip(this.hoveredCallsite.location, width, ctx, lines), |
| ); |
| } |
| textWidth = Math.max( |
| textWidth, |
| this.addToTooltip(this.hoveredCallsite.mapping, width, ctx, lines), |
| ); |
| |
| if (this.nodeRendering.totalSize !== undefined) { |
| const percentage = |
| (this.hoveredCallsite.totalSize / this.totalSize) * 100; |
| const totalSizeText = `${ |
| this.nodeRendering.totalSize |
| }: ${this.displaySize( |
| this.hoveredCallsite.totalSize, |
| unit, |
| unit === 'B' ? 1024 : 1000, |
| )} (${percentage.toFixed(2)}%)`; |
| textWidth = Math.max( |
| textWidth, |
| this.addToTooltip(totalSizeText, width, ctx, lines), |
| ); |
| } |
| |
| if ( |
| this.nodeRendering.selfSize !== undefined && |
| this.hoveredCallsite.selfSize > 0 |
| ) { |
| const selfPercentage = |
| (this.hoveredCallsite.selfSize / this.totalSize) * 100; |
| const selfSizeText = `${ |
| this.nodeRendering.selfSize |
| }: ${this.displaySize( |
| this.hoveredCallsite.selfSize, |
| unit, |
| unit === 'B' ? 1024 : 1000, |
| )} (${selfPercentage.toFixed(2)}%)`; |
| textWidth = Math.max( |
| textWidth, |
| this.addToTooltip(selfSizeText, width, ctx, lines), |
| ); |
| } |
| |
| // Compute a line height as the bounding box height + 50%: |
| const heightSample = ctx.measureText( |
| 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', |
| ); |
| const lineHeight = Math.round( |
| heightSample.actualBoundingBoxDescent * 1.5, |
| ); |
| |
| const rectWidth = textWidth + 2 * paddingPx; |
| const rectHeight = lineHeight * lines.length + 2 * paddingPx; |
| |
| let rectXStart = this.hoveredX + offsetPx; |
| let rectYStart = this.hoveredY + offsetPx; |
| |
| if (rectXStart + rectWidth > width) { |
| rectXStart = width - rectWidth; |
| } |
| |
| if (rectYStart + rectHeight > height) { |
| rectYStart = height - rectHeight; |
| } |
| |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; |
| ctx.fillRect(rectXStart, rectYStart, rectWidth, rectHeight); |
| ctx.fillStyle = 'hsl(200, 50%, 40%)'; |
| ctx.textAlign = 'left'; |
| for (let i = 0; i < lines.length; i++) { |
| const line = lines[i]; |
| ctx.fillText( |
| line, |
| rectXStart + paddingPx, |
| rectYStart + paddingPx + i * lineHeight, |
| ); |
| } |
| } |
| } |
| |
| private addToTooltip( |
| text: string, |
| width: number, |
| ctx: CanvasRenderingContext2D, |
| lines: string[], |
| ): number { |
| const lineSplitter: LineSplitter = splitIfTooBig( |
| text, |
| width, |
| ctx.measureText(text).width, |
| ); |
| lines.push(...lineSplitter.lines); |
| return lineSplitter.lineWidth; |
| } |
| |
| private getCallsiteName(value: CallsiteInfo): string { |
| return value.name === undefined || value.name === '' |
| ? 'unknown' |
| : value.name; |
| } |
| |
| private displaySize(totalSize: number, unit: string, step = 1024): string { |
| if (unit === '') return totalSize.toLocaleString(); |
| if (totalSize === 0) return `0 ${unit}`; |
| const units = [ |
| ['', 1], |
| ['K', step], |
| ['M', Math.pow(step, 2)], |
| ['G', Math.pow(step, 3)], |
| ]; |
| let unitsIndex = Math.trunc(Math.log(totalSize) / Math.log(step)); |
| unitsIndex = unitsIndex > units.length - 1 ? units.length - 1 : unitsIndex; |
| const result = totalSize / +units[unitsIndex][1]; |
| const resultString = |
| totalSize % +units[unitsIndex][1] === 0 |
| ? result.toString() |
| : result.toFixed(2); |
| return `${resultString} ${units[unitsIndex][0]}${unit}`; |
| } |
| |
| onMouseMove({x, y}: {x: number; y: number}) { |
| this.hoveredX = x; |
| this.hoveredY = y; |
| this.hoveredCallsite = this.findSelectedCallsite(x, y); |
| const isCallsiteSelected = this.hoveredCallsite !== undefined; |
| if (!isCallsiteSelected) { |
| this.onMouseOut(); |
| } |
| return isCallsiteSelected; |
| } |
| |
| onMouseOut() { |
| this.hoveredX = -1; |
| this.hoveredY = -1; |
| this.hoveredCallsite = undefined; |
| } |
| |
| onMouseClick({x, y}: {x: number; y: number}): CallsiteInfo | undefined { |
| const clickedCallsite = this.findSelectedCallsite(x, y); |
| // TODO(b/148596659): Allow to expand [merged] callsites. Currently, |
| // this expands to the biggest of the nodes that were merged, which |
| // is confusing, so we disallow clicking on them. |
| if (clickedCallsite === undefined || clickedCallsite.merged) { |
| return undefined; |
| } |
| return clickedCallsite; |
| } |
| |
| private findSelectedCallsite(x: number, y: number): CallsiteInfo | undefined { |
| const depth = Math.trunc((y - this.startingY) / NODE_HEIGHT) - 1; // at 0 is root |
| if (depth >= 0 && this.xStartsPerDepth.has(depth)) { |
| const startX = this.searchSmallest(this.xStartsPerDepth.get(depth)!, x); |
| const result = this.graphData.get(`${depth};${startX}`); |
| if (result !== undefined) { |
| const width = result.width; |
| return startX + width >= x ? result.callsite : undefined; |
| } |
| } |
| return undefined; |
| } |
| |
| searchSmallest(haystack: number[], needle: number): number { |
| haystack = haystack.sort((n1, n2) => n1 - n2); |
| const [left] = searchSegment(haystack, needle); |
| return left === -1 ? -1 : haystack[left]; |
| } |
| |
| getHeight(): number { |
| return this.flamegraphData.length === 0 |
| ? 0 |
| : (this.maxDepth + 2) * NODE_HEIGHT; |
| } |
| } |
| |
| export interface LineSplitter { |
| lineWidth: number; |
| lines: string[]; |
| } |
| |
| export function splitIfTooBig( |
| line: string, |
| width: number, |
| lineWidth: number, |
| ): LineSplitter { |
| if (line === '') return {lineWidth, lines: []}; |
| const lines: string[] = []; |
| const charWidth = lineWidth / line.length; |
| const maxWidth = width - 32; |
| const maxLineLen = Math.trunc(maxWidth / charWidth); |
| while (line.length > 0) { |
| lines.push(line.slice(0, maxLineLen)); |
| line = line.slice(maxLineLen); |
| } |
| lineWidth = Math.min(maxLineLen * charWidth, lineWidth); |
| return {lineWidth, lines}; |
| } |