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 32const NODE_HEIGHT_DEFAULT = 15; 33 34export const HEAP_PROFILE_COLOR = 'hsl(224, 45%, 70%)'; 35export const HEAP_PROFILE_HOVERED_COLOR = 'hsl(224, 45%, 55%)'; 36 37export function findRootSize(data: 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 isThumbnail = false; 54 private nodeRendering: NodeRendering = {}; 55 private flamegraphData: CallsiteInfo[]; 56 private maxDepth = -1; 57 private totalSize = -1; 58 private textSize = 12; 59 // Key for the map is depth followed by x coordinate - `depth;x` 60 private graphData: Map<string, CallsiteInfoWidth> = new Map(); 61 private xStartsPerDepth: Map<number, number[]> = new Map(); 62 63 private hoveredX = -1; 64 private hoveredY = -1; 65 private hoveredCallsite?: CallsiteInfo; 66 private clickedCallsite?: CallsiteInfo; 67 68 private startingY = 0; 69 70 constructor(flamegraphData: CallsiteInfo[]) { 71 this.flamegraphData = flamegraphData; 72 this.findMaxDepth(); 73 } 74 75 private findMaxDepth() { 76 this.maxDepth = Math.max(...this.flamegraphData.map(value => value.depth)); 77 } 78 79 hash(s: string): number { 80 let hash = 0x811c9dc5 & 0xfffffff; 81 for (let i = 0; i < s.length; i++) { 82 hash ^= s.charCodeAt(i); 83 hash = (hash * 16777619) & 0xffffffff; 84 } 85 return hash & 0xff; 86 } 87 88 generateColor(name: string, isGreyedOut = false): string { 89 if (this.isThumbnail) { 90 return HEAP_PROFILE_COLOR; 91 } 92 if (isGreyedOut) { 93 return '#d9d9d9'; 94 } 95 if (name === 'unknown' || name === 'root') { 96 return '#c0c0c0'; 97 } 98 const hue = this.hash(name); 99 return `hsl(${hue}, 50%, 65%)`; 100 } 101 102 /** 103 * Caller will have to call draw method ater updating data to have updated 104 * graph. 105 */ 106 updateDataIfChanged( 107 nodeRendering: NodeRendering, flamegraphData: CallsiteInfo[], 108 clickedCallsite?: CallsiteInfo) { 109 this.nodeRendering = nodeRendering; 110 this.clickedCallsite = clickedCallsite; 111 if (this.flamegraphData === flamegraphData) { 112 return; 113 } 114 this.flamegraphData = flamegraphData; 115 this.clickedCallsite = clickedCallsite; 116 this.findMaxDepth(); 117 // Finding total size of roots. 118 this.totalSize = findRootSize(flamegraphData); 119 } 120 121 draw( 122 ctx: CanvasRenderingContext2D, width: number, height: number, x = 0, 123 y = 0, unit = 'B') { 124 // TODO(taylori): Instead of pesimistic approach improve displaying text. 125 const name = '____MMMMMMQQwwZZZZZZzzzzzznnnnnnwwwwwwWWWWWqq$$mmmmmm__'; 126 const charWidth = ctx.measureText(name).width / name.length; 127 const nodeHeight = this.getNodeHeight(); 128 this.startingY = y; 129 130 if (this.flamegraphData === undefined) { 131 return; 132 } 133 // For each node, we use this map to get information about it's parent 134 // (total size of it, width and where it starts in graph) so we can 135 // calculate it's own position in graph. 136 const nodesMap = new Map<number, Node>(); 137 let currentY = y; 138 nodesMap.set(-1, {width, nextXForChildren: x, size: this.totalSize, x}); 139 140 // Initialize data needed for click/hover behaivior. 141 this.graphData = new Map(); 142 this.xStartsPerDepth = new Map(); 143 144 // Draw root node. 145 ctx.fillStyle = this.generateColor('root', false); 146 ctx.fillRect(x, currentY, width, nodeHeight); 147 ctx.font = `${this.textSize}px Roboto Condensed`; 148 const text = cropText( 149 `root: ${ 150 this.displaySize( 151 this.totalSize, unit, unit === 'B' ? 1024 : 1000)}`, 152 charWidth, 153 width - 2); 154 ctx.fillStyle = 'black'; 155 ctx.fillText(text, x + 5, currentY + nodeHeight - 4); 156 currentY += nodeHeight; 157 158 159 for (let i = 0; i < this.flamegraphData.length; i++) { 160 if (currentY > height) { 161 break; 162 } 163 const value = this.flamegraphData[i]; 164 const parentNode = nodesMap.get(value.parentId); 165 if (parentNode === undefined) { 166 continue; 167 } 168 169 const isClicked = !this.isThumbnail && this.clickedCallsite !== undefined; 170 const isFullWidth = 171 isClicked && value.depth <= this.clickedCallsite!.depth; 172 const isGreyedOut = 173 isClicked && value.depth < this.clickedCallsite!.depth; 174 175 const parent = value.parentId; 176 const parentSize = parent === -1 ? this.totalSize : parentNode.size; 177 // Calculate node's width based on its proportion in parent. 178 const width = 179 (isFullWidth ? 1 : value.totalSize / parentSize) * parentNode.width; 180 181 const currentX = parentNode.nextXForChildren; 182 currentY = y + nodeHeight * (value.depth + 1); 183 184 // Draw node. 185 const name = this.getCallsiteName(value); 186 ctx.fillStyle = this.generateColor(name, isGreyedOut); 187 ctx.fillRect(currentX, currentY, width, nodeHeight); 188 189 // Set current node's data in map for children to use. 190 nodesMap.set(value.id, { 191 width, 192 nextXForChildren: currentX, 193 size: value.totalSize, 194 x: currentX 195 }); 196 // Update next x coordinate in parent. 197 nodesMap.set(value.parentId, { 198 width: parentNode.width, 199 nextXForChildren: currentX + width, 200 size: parentNode.size, 201 x: parentNode.x 202 }); 203 204 // Thumbnail mode doesn't have name on nodes and click/hover behaviour. 205 if (this.isThumbnail) { 206 continue; 207 } 208 209 // Draw name. 210 ctx.font = `${this.textSize}px Roboto Condensed`; 211 const text = cropText(name, charWidth, width - 2); 212 ctx.fillStyle = 'black'; 213 ctx.fillText(text, currentX + 5, currentY + nodeHeight - 4); 214 215 // Draw border around node. 216 ctx.strokeStyle = 'white'; 217 ctx.beginPath(); 218 ctx.moveTo(currentX, currentY); 219 ctx.lineTo(currentX, currentY + nodeHeight); 220 ctx.lineTo(currentX + width, currentY + nodeHeight); 221 ctx.lineTo(currentX + width, currentY); 222 ctx.moveTo(currentX, currentY); 223 ctx.lineWidth = 1; 224 ctx.closePath(); 225 ctx.stroke(); 226 227 // Add this node for recognizing in click/hover. 228 // Map graphData contains one callsite which is on that depth and X 229 // start. Map xStartsPerDepth for each depth contains all X start 230 // coordinates that callsites on that level have. 231 this.graphData.set( 232 `${value.depth};${currentX}`, {callsite: value, width}); 233 const xStarts = this.xStartsPerDepth.get(value.depth); 234 if (xStarts === undefined) { 235 this.xStartsPerDepth.set(value.depth, [currentX]); 236 } else { 237 xStarts.push(currentX); 238 } 239 } 240 241 if (this.hoveredX > -1 && this.hoveredY > -1 && this.hoveredCallsite) { 242 // Draw the tooltip. 243 const lines: string[] = []; 244 let lineSplitter: LineSplitter; 245 const nameText = this.getCallsiteName(this.hoveredCallsite); 246 lineSplitter = 247 splitIfTooBig(nameText, width, ctx.measureText(nameText).width); 248 let textWidth = lineSplitter.lineWidth; 249 lines.push(...lineSplitter.lines); 250 251 const mappingText = this.hoveredCallsite.mapping; 252 lineSplitter = 253 splitIfTooBig(mappingText, width, ctx.measureText(mappingText).width); 254 textWidth = Math.max(textWidth, lineSplitter.lineWidth); 255 lines.push(...lineSplitter.lines); 256 257 if (this.nodeRendering.totalSize !== undefined) { 258 const percentage = 259 this.hoveredCallsite.totalSize / this.totalSize * 100; 260 const totalSizeText = `${this.nodeRendering.totalSize}: ${ 261 this.displaySize( 262 this.hoveredCallsite.totalSize, 263 unit, 264 unit === 'B' ? 1024 : 1000)} (${percentage.toFixed(2)}%)`; 265 lineSplitter = splitIfTooBig( 266 totalSizeText, width, ctx.measureText(totalSizeText).width); 267 textWidth = Math.max(textWidth, lineSplitter.lineWidth); 268 lines.push(...lineSplitter.lines); 269 } 270 271 if (this.nodeRendering.selfSize !== undefined && 272 this.hoveredCallsite.selfSize > 0) { 273 const selfPercentage = 274 this.hoveredCallsite.selfSize / this.totalSize * 100; 275 const selfSizeText = `${this.nodeRendering.selfSize}: ${ 276 this.displaySize( 277 this.hoveredCallsite.selfSize, 278 unit, 279 unit === 'B' ? 1024 : 1000)} (${selfPercentage.toFixed(2)}%)`; 280 lineSplitter = splitIfTooBig( 281 selfSizeText, width, ctx.measureText(selfSizeText).width); 282 textWidth = Math.max(textWidth, lineSplitter.lineWidth); 283 lines.push(...lineSplitter.lines); 284 } 285 286 const rectWidth = textWidth + 16; 287 const rectXStart = this.hoveredX + 8 + rectWidth > width ? 288 width - rectWidth - 8 : 289 this.hoveredX + 8; 290 const rectHeight = nodeHeight * (lines.length + 1); 291 const rectYStart = this.hoveredY + 4 + rectHeight > height ? 292 height - rectHeight - 8 : 293 this.hoveredY + 4; 294 295 ctx.font = '12px Roboto Condensed'; 296 ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; 297 ctx.fillRect(rectXStart, rectYStart, rectWidth, rectHeight); 298 ctx.fillStyle = 'hsl(200, 50%, 40%)'; 299 ctx.textAlign = 'left'; 300 for (let i = 0; i < lines.length; i++) { 301 const line = lines[i]; 302 ctx.fillText(line, rectXStart + 4, rectYStart + (i + 1) * 18); 303 } 304 } 305 } 306 307 private getCallsiteName(value: CallsiteInfo): string { 308 return value.name === undefined || value.name === '' ? 'unknown' : 309 value.name; 310 } 311 312 private displaySize(totalSize: number, unit: string, step = 1024): string { 313 if (unit === '') return totalSize.toLocaleString(); 314 if (totalSize === 0) return `0 ${unit}`; 315 const units = [ 316 ['', 1], 317 ['K', step], 318 ['M', Math.pow(step, 2)], 319 ['G', Math.pow(step, 3)] 320 ]; 321 let unitsIndex = Math.trunc(Math.log(totalSize) / Math.log(step)); 322 unitsIndex = unitsIndex > units.length - 1 ? units.length - 1 : unitsIndex; 323 const result = totalSize / +units[unitsIndex][1]; 324 const resultString = totalSize % +units[unitsIndex][1] === 0 ? 325 result.toString() : 326 result.toFixed(2); 327 return `${resultString} ${units[unitsIndex][0]}${unit}`; 328 } 329 330 onMouseMove({x, y}: {x: number, y: number}) { 331 this.hoveredX = x; 332 this.hoveredY = y; 333 this.hoveredCallsite = this.findSelectedCallsite(x, y); 334 const isCallsiteSelected = this.hoveredCallsite !== undefined; 335 if (!isCallsiteSelected) { 336 this.onMouseOut(); 337 } 338 return isCallsiteSelected; 339 } 340 341 onMouseOut() { 342 this.hoveredX = -1; 343 this.hoveredY = -1; 344 this.hoveredCallsite = undefined; 345 } 346 347 onMouseClick({x, y}: {x: number, y: number}): CallsiteInfo|undefined { 348 if (this.isThumbnail) { 349 return undefined; 350 } 351 const clickedCallsite = this.findSelectedCallsite(x, y); 352 // TODO(b/148596659): Allow to expand [merged] callsites. Currently, 353 // this expands to the biggest of the nodes that were merged, which 354 // is confusing, so we disallow clicking on them. 355 if (clickedCallsite === undefined || clickedCallsite.merged) { 356 return undefined; 357 } 358 return clickedCallsite; 359 } 360 361 private findSelectedCallsite(x: number, y: number): CallsiteInfo|undefined { 362 const depth = Math.trunc((y - this.startingY) / this.getNodeHeight()) - 363 1; // at 0 is root 364 if (depth >= 0 && this.xStartsPerDepth.has(depth)) { 365 const startX = this.searchSmallest(this.xStartsPerDepth.get(depth)!, x); 366 const result = this.graphData.get(`${depth};${startX}`); 367 if (result !== undefined) { 368 const width = result.width; 369 return startX + width >= x ? result.callsite : undefined; 370 } 371 } 372 return undefined; 373 } 374 375 searchSmallest(haystack: number[], needle: number): number { 376 haystack = haystack.sort((n1, n2) => n1 - n2); 377 const [left, ] = searchSegment(haystack, needle); 378 return left === -1 ? -1 : haystack[left]; 379 } 380 381 getHeight(): number { 382 return this.flamegraphData.length === 0 ? 383 0 : 384 (this.maxDepth + 2) * this.getNodeHeight(); 385 } 386 387 getNodeHeight() { 388 return this.isThumbnail ? 1 : NODE_HEIGHT_DEFAULT; 389 } 390 391 enableThumbnail(isThumbnail: boolean) { 392 this.isThumbnail = isThumbnail; 393 } 394} 395 396export interface LineSplitter { 397 lineWidth: number; 398 lines: string[]; 399} 400 401export function splitIfTooBig( 402 line: string, width: number, lineWidth: number): LineSplitter { 403 if (line === '') return {lineWidth, lines: []}; 404 const lines: string[] = []; 405 const charWidth = lineWidth / line.length; 406 const maxWidth = width - 32; 407 const maxLineLen = Math.trunc(maxWidth / charWidth); 408 while (line.length > 0) { 409 lines.push(line.slice(0, maxLineLen)); 410 line = line.slice(maxLineLen); 411 } 412 lineWidth = Math.min(maxWidth, lineWidth); 413 return {lineWidth, lines}; 414} 415