1// Copyright (C) 2018 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {searchSegment} from '../base/binary_search'; 16import {cropText} from '../common/canvas_utils'; 17 18import {CallsiteInfo} from '../common/state'; 19 20interface Node { 21 width: number; 22 x: number; 23 nextXForChildren: number; 24 size: number; 25} 26 27interface CallsiteInfoWidth { 28 callsite: CallsiteInfo; 29 width: number; 30} 31 32// Height of one 'row' on the flame chart including 1px of whitespace 33// below the box. 34const NODE_HEIGHT = 18; 35 36export const FLAMEGRAPH_HOVERED_COLOR = 'hsl(224, 45%, 55%)'; 37 38export function findRootSize(data: CallsiteInfo[]) { 39 let totalSize = 0; 40 let i = 0; 41 while (i < data.length && data[i].depth === 0) { 42 totalSize += data[i].totalSize; 43 i++; 44 } 45 return totalSize; 46} 47 48export interface NodeRendering { 49 totalSize?: string; 50 selfSize?: string; 51} 52 53export class Flamegraph { 54 private nodeRendering: NodeRendering = {}; 55 private flamegraphData: CallsiteInfo[]; 56 private highlightSomeNodes = false; 57 private maxDepth = -1; 58 private totalSize = -1; 59 // Initialised on first draw() call 60 private labelCharWidth = 0; 61 private labelFontStyle = '12px Roboto Mono'; 62 private rolloverFontStyle = '12px Roboto Condensed'; 63 // Key for the map is depth followed by x coordinate - `depth;x` 64 private graphData: Map<string, CallsiteInfoWidth> = new Map(); 65 private xStartsPerDepth: Map<number, number[]> = new Map(); 66 67 private hoveredX = -1; 68 private hoveredY = -1; 69 private hoveredCallsite?: CallsiteInfo; 70 private clickedCallsite?: CallsiteInfo; 71 72 private startingY = 0; 73 74 constructor(flamegraphData: CallsiteInfo[]) { 75 this.flamegraphData = flamegraphData; 76 this.findMaxDepth(); 77 } 78 79 private findMaxDepth() { 80 this.maxDepth = Math.max(...this.flamegraphData.map(value => value.depth)); 81 } 82 83 // Instead of highlighting the interesting nodes, we actually want to 84 // de-emphasize the non-highlighted nodes. Returns true if there 85 // are any highlighted nodes in the flamegraph. 86 private highlightingExists() { 87 this.highlightSomeNodes = this.flamegraphData.some((e) => e.highlighted); 88 } 89 90 generateColor(name: string, isGreyedOut = false, highlighted: boolean): 91 string { 92 if (isGreyedOut) { 93 return '#d9d9d9'; 94 } 95 if (name === 'unknown' || name === 'root') { 96 return '#c0c0c0'; 97 } 98 let x = 0; 99 for (let i = 0; i < name.length; i += 1) { 100 x += name.charCodeAt(i) % 64; 101 } 102 x = x % 360; 103 let l = '76'; 104 // Make non-highlighted node lighter. 105 if (this.highlightSomeNodes && !highlighted) { 106 l = '90'; 107 } 108 return `hsl(${x}deg, 45%, ${l}%)`; 109 } 110 111 /** 112 * Caller will have to call draw method after updating data to have updated 113 * graph. 114 */ 115 updateDataIfChanged( 116 nodeRendering: NodeRendering, flamegraphData: CallsiteInfo[], 117 clickedCallsite?: CallsiteInfo) { 118 this.nodeRendering = nodeRendering; 119 this.clickedCallsite = clickedCallsite; 120 if (this.flamegraphData === flamegraphData) { 121 return; 122 } 123 this.flamegraphData = flamegraphData; 124 this.clickedCallsite = clickedCallsite; 125 this.findMaxDepth(); 126 this.highlightingExists(); 127 // Finding total size of roots. 128 this.totalSize = findRootSize(flamegraphData); 129 } 130 131 draw( 132 ctx: CanvasRenderingContext2D, width: number, height: number, x = 0, 133 y = 0, unit = 'B') { 134 135 if (this.flamegraphData === undefined) { 136 return; 137 } 138 139 ctx.font = this.labelFontStyle; 140 ctx.textBaseline = 'middle'; 141 if (this.labelCharWidth === 0) { 142 this.labelCharWidth = ctx.measureText('_').width; 143 } 144 145 this.startingY = y; 146 147 // For each node, we use this map to get information about it's parent 148 // (total size of it, width and where it starts in graph) so we can 149 // calculate it's own position in graph. 150 const nodesMap = new Map<number, Node>(); 151 let currentY = y; 152 nodesMap.set(-1, {width, nextXForChildren: x, size: this.totalSize, x}); 153 154 // Initialize data needed for click/hover behavior. 155 this.graphData = new Map(); 156 this.xStartsPerDepth = new Map(); 157 158 // Draw root node. 159 ctx.fillStyle = this.generateColor('root', false, false); 160 ctx.fillRect(x, currentY, width, NODE_HEIGHT - 1); 161 const text = cropText( 162 `root: ${ 163 this.displaySize( 164 this.totalSize, unit, unit === 'B' ? 1024 : 1000)}`, 165 this.labelCharWidth, 166 width - 2); 167 ctx.fillStyle = 'black'; 168 ctx.fillText(text, x + 5, currentY + (NODE_HEIGHT - 1) / 2); 169 currentY += NODE_HEIGHT; 170 171 // Set style for borders. 172 ctx.strokeStyle = 'white'; 173 ctx.lineWidth = 0.5; 174 175 for (let i = 0; i < this.flamegraphData.length; i++) { 176 if (currentY > height) { 177 break; 178 } 179 const value = this.flamegraphData[i]; 180 const parentNode = nodesMap.get(value.parentId); 181 if (parentNode === undefined) { 182 continue; 183 } 184 185 const isClicked = this.clickedCallsite !== undefined; 186 const isFullWidth = 187 isClicked && value.depth <= this.clickedCallsite!.depth; 188 const isGreyedOut = 189 isClicked && value.depth < this.clickedCallsite!.depth; 190 191 const parent = value.parentId; 192 const parentSize = parent === -1 ? this.totalSize : parentNode.size; 193 // Calculate node's width based on its proportion in parent. 194 const width = 195 (isFullWidth ? 1 : value.totalSize / parentSize) * parentNode.width; 196 197 const currentX = parentNode.nextXForChildren; 198 currentY = y + NODE_HEIGHT * (value.depth + 1); 199 200 // Draw node. 201 const name = this.getCallsiteName(value); 202 ctx.fillStyle = this.generateColor(name, isGreyedOut, value.highlighted); 203 ctx.fillRect(currentX, currentY, width, NODE_HEIGHT - 1); 204 205 // Set current node's data in map for children to use. 206 nodesMap.set(value.id, { 207 width, 208 nextXForChildren: currentX, 209 size: value.totalSize, 210 x: currentX 211 }); 212 // Update next x coordinate in parent. 213 nodesMap.set(value.parentId, { 214 width: parentNode.width, 215 nextXForChildren: currentX + width, 216 size: parentNode.size, 217 x: parentNode.x 218 }); 219 220 // Draw name. 221 const labelPaddingPx = 5; 222 const maxLabelWidth = width - labelPaddingPx * 2; 223 let text = cropText(name, this.labelCharWidth, maxLabelWidth); 224 // If cropped text and the original text are within 20% we keep the 225 // original text and just squish it a bit. 226 if (text.length * 1.2 > name.length) { 227 text = name; 228 } 229 ctx.fillStyle = 'black'; 230 ctx.fillText( 231 text, 232 currentX + labelPaddingPx, 233 currentY + (NODE_HEIGHT - 1) / 2, 234 maxLabelWidth); 235 236 // Draw border on the right of node. 237 ctx.beginPath(); 238 ctx.moveTo(currentX + width, currentY); 239 ctx.lineTo(currentX + width, currentY + NODE_HEIGHT); 240 ctx.stroke(); 241 242 // Add this node for recognizing in click/hover. 243 // Map graphData contains one callsite which is on that depth and X 244 // start. Map xStartsPerDepth for each depth contains all X start 245 // coordinates that callsites on that level have. 246 this.graphData.set( 247 `${value.depth};${currentX}`, {callsite: value, width}); 248 const xStarts = this.xStartsPerDepth.get(value.depth); 249 if (xStarts === undefined) { 250 this.xStartsPerDepth.set(value.depth, [currentX]); 251 } else { 252 xStarts.push(currentX); 253 } 254 } 255 256 // Draw the tooltip. 257 if (this.hoveredX > -1 && this.hoveredY > -1 && this.hoveredCallsite) { 258 // Must set these before measureText below. 259 ctx.font = this.rolloverFontStyle; 260 ctx.textBaseline = 'top'; 261 262 // Size in px of the border around the text and the edge of the rollover 263 // background. 264 const paddingPx = 8; 265 // Size in px of the x and y offset between the mouse and the top left 266 // corner of the rollover box. 267 const offsetPx = 4; 268 269 const lines: string[] = []; 270 271 let textWidth = this.addToTooltip( 272 this.getCallsiteName(this.hoveredCallsite), 273 width - paddingPx, 274 ctx, 275 lines); 276 if (this.hoveredCallsite.location != null) { 277 textWidth = Math.max( 278 textWidth, 279 this.addToTooltip( 280 this.hoveredCallsite.location, width, ctx, lines)); 281 } 282 textWidth = Math.max( 283 textWidth, 284 this.addToTooltip(this.hoveredCallsite.mapping, width, ctx, lines)); 285 286 if (this.nodeRendering.totalSize !== undefined) { 287 const percentage = 288 this.hoveredCallsite.totalSize / this.totalSize * 100; 289 const totalSizeText = `${this.nodeRendering.totalSize}: ${ 290 this.displaySize( 291 this.hoveredCallsite.totalSize, 292 unit, 293 unit === 'B' ? 1024 : 1000)} (${percentage.toFixed(2)}%)`; 294 textWidth = Math.max( 295 textWidth, this.addToTooltip(totalSizeText, width, ctx, lines)); 296 } 297 298 if (this.nodeRendering.selfSize !== undefined && 299 this.hoveredCallsite.selfSize > 0) { 300 const selfPercentage = 301 this.hoveredCallsite.selfSize / this.totalSize * 100; 302 const selfSizeText = `${this.nodeRendering.selfSize}: ${ 303 this.displaySize( 304 this.hoveredCallsite.selfSize, 305 unit, 306 unit === 'B' ? 1024 : 1000)} (${selfPercentage.toFixed(2)}%)`; 307 textWidth = Math.max( 308 textWidth, this.addToTooltip(selfSizeText, width, ctx, lines)); 309 } 310 311 // Compute a line height as the bounding box height + 50%: 312 const heightSample = ctx.measureText( 313 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); 314 const lineHeight = 315 Math.round(heightSample.actualBoundingBoxDescent * 1.5); 316 317 const rectWidth = textWidth + 2 * paddingPx; 318 const rectHeight = lineHeight * lines.length + 2 * paddingPx; 319 320 let rectXStart = this.hoveredX + offsetPx; 321 let rectYStart = this.hoveredY + offsetPx; 322 323 if (rectXStart + rectWidth > width) { 324 rectXStart = width - rectWidth; 325 } 326 327 if (rectYStart + rectHeight > height) { 328 rectYStart = height - rectHeight; 329 } 330 331 ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; 332 ctx.fillRect(rectXStart, rectYStart, rectWidth, rectHeight); 333 ctx.fillStyle = 'hsl(200, 50%, 40%)'; 334 ctx.textAlign = 'left'; 335 for (let i = 0; i < lines.length; i++) { 336 const line = lines[i]; 337 ctx.fillText( 338 line, 339 rectXStart + paddingPx, 340 rectYStart + paddingPx + i * lineHeight); 341 } 342 } 343 } 344 345 private addToTooltip( 346 text: string, width: number, ctx: CanvasRenderingContext2D, 347 lines: string[]): number { 348 const lineSplitter: LineSplitter = 349 splitIfTooBig(text, width, ctx.measureText(text).width); 350 lines.push(...lineSplitter.lines); 351 return lineSplitter.lineWidth; 352 } 353 354 private getCallsiteName(value: CallsiteInfo): string { 355 return value.name === undefined || value.name === '' ? 'unknown' : 356 value.name; 357 } 358 359 private displaySize(totalSize: number, unit: string, step = 1024): string { 360 if (unit === '') return totalSize.toLocaleString(); 361 if (totalSize === 0) return `0 ${unit}`; 362 const units = [ 363 ['', 1], 364 ['K', step], 365 ['M', Math.pow(step, 2)], 366 ['G', Math.pow(step, 3)] 367 ]; 368 let unitsIndex = Math.trunc(Math.log(totalSize) / Math.log(step)); 369 unitsIndex = unitsIndex > units.length - 1 ? units.length - 1 : unitsIndex; 370 const result = totalSize / +units[unitsIndex][1]; 371 const resultString = totalSize % +units[unitsIndex][1] === 0 ? 372 result.toString() : 373 result.toFixed(2); 374 return `${resultString} ${units[unitsIndex][0]}${unit}`; 375 } 376 377 onMouseMove({x, y}: {x: number, y: number}) { 378 this.hoveredX = x; 379 this.hoveredY = y; 380 this.hoveredCallsite = this.findSelectedCallsite(x, y); 381 const isCallsiteSelected = this.hoveredCallsite !== undefined; 382 if (!isCallsiteSelected) { 383 this.onMouseOut(); 384 } 385 return isCallsiteSelected; 386 } 387 388 onMouseOut() { 389 this.hoveredX = -1; 390 this.hoveredY = -1; 391 this.hoveredCallsite = undefined; 392 } 393 394 onMouseClick({x, y}: {x: number, y: number}): CallsiteInfo|undefined { 395 const clickedCallsite = this.findSelectedCallsite(x, y); 396 // TODO(b/148596659): Allow to expand [merged] callsites. Currently, 397 // this expands to the biggest of the nodes that were merged, which 398 // is confusing, so we disallow clicking on them. 399 if (clickedCallsite === undefined || clickedCallsite.merged) { 400 return undefined; 401 } 402 return clickedCallsite; 403 } 404 405 private findSelectedCallsite(x: number, y: number): CallsiteInfo|undefined { 406 const depth = 407 Math.trunc((y - this.startingY) / NODE_HEIGHT) - 1; // at 0 is root 408 if (depth >= 0 && this.xStartsPerDepth.has(depth)) { 409 const startX = this.searchSmallest(this.xStartsPerDepth.get(depth)!, x); 410 const result = this.graphData.get(`${depth};${startX}`); 411 if (result !== undefined) { 412 const width = result.width; 413 return startX + width >= x ? result.callsite : undefined; 414 } 415 } 416 return undefined; 417 } 418 419 searchSmallest(haystack: number[], needle: number): number { 420 haystack = haystack.sort((n1, n2) => n1 - n2); 421 const [left, ] = searchSegment(haystack, needle); 422 return left === -1 ? -1 : haystack[left]; 423 } 424 425 getHeight(): number { 426 return this.flamegraphData.length === 0 ? 0 : 427 (this.maxDepth + 2) * NODE_HEIGHT; 428 } 429} 430 431export interface LineSplitter { 432 lineWidth: number; 433 lines: string[]; 434} 435 436export function splitIfTooBig( 437 line: string, width: number, lineWidth: number): LineSplitter { 438 if (line === '') return {lineWidth, lines: []}; 439 const lines: string[] = []; 440 const charWidth = lineWidth / line.length; 441 const maxWidth = width - 32; 442 const maxLineLen = Math.trunc(maxWidth / charWidth); 443 while (line.length > 0) { 444 lines.push(line.slice(0, maxLineLen)); 445 line = line.slice(maxLineLen); 446 } 447 lineWidth = Math.min(maxLineLen * charWidth, lineWidth); 448 return {lineWidth, lines}; 449} 450