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