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