github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/packages/pyroscope-flamegraph/src/FlameGraph/FlameGraphComponent/Flamegraph_render.ts (about) 1 /* 2 3 This component is based on code from flamebearer project 4 https://github.com/mapbox/flamebearer 5 6 ISC License 7 8 Copyright (c) 2018, Mapbox 9 10 Permission to use, copy, modify, and/or distribute this software for any purpose 11 with or without fee is hereby granted, provided that the above copyright notice 12 and this permission notice appear in all copies. 13 14 THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 15 REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 16 FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 17 INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 18 OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 19 TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 20 THIS SOFTWARE. 21 22 */ 23 24 /* eslint-disable no-continue */ 25 import { createFF, Flamebearer, SpyName } from '@pyroscope/models/src'; 26 import { 27 formatPercent, 28 getFormatter, 29 ratioToPercent, 30 } from '../../format/format'; 31 import { fitToCanvasRect } from '../../fitMode/fitMode'; 32 import { getRatios } from './utils'; 33 import { 34 PX_PER_LEVEL, 35 COLLAPSE_THRESHOLD, 36 LABEL_THRESHOLD, 37 BAR_HEIGHT, 38 GAP, 39 } from './constants'; 40 import { 41 colorBasedOnDiffPercent, 42 colorBasedOnPackageName, 43 colorGreyscale, 44 getPackageNameFromStackTrace, 45 } from './color'; 46 import type { FlamegraphPalette } from './colorPalette'; 47 import { isMatch } from '../../search'; 48 // there's a dependency cycle here but it should be fine 49 /* eslint-disable-next-line import/no-cycle */ 50 import Flamegraph from './Flamegraph'; 51 52 type CanvasRendererConfig = Flamebearer & { 53 canvas: HTMLCanvasElement; 54 focusedNode: ConstructorParameters<typeof Flamegraph>[2]; 55 fitMode: ConstructorParameters<typeof Flamegraph>[3]; 56 highlightQuery: ConstructorParameters<typeof Flamegraph>[4]; 57 zoom: ConstructorParameters<typeof Flamegraph>[5]; 58 59 /** 60 * Used when zooming, values between 0 and 1. 61 * For illustration, in a non zoomed state it has the value of 0 62 */ 63 readonly rangeMin: number; 64 /** 65 * Used when zooming, values between 0 and 1. 66 * For illustration, in a non zoomed state it has the value of 1 67 */ 68 readonly rangeMax: number; 69 70 tickToX: (i: number) => number; 71 72 pxPerTick: number; 73 74 palette: FlamegraphPalette; 75 maxSelf?: number; 76 }; 77 78 export default function RenderCanvas(props: CanvasRendererConfig) { 79 const { canvas, fitMode, units, tickToX, levels, palette } = props; 80 const { numTicks, sampleRate, pxPerTick } = props; 81 const { rangeMin, rangeMax } = props; 82 const { focusedNode, zoom } = props; 83 84 const graphWidth = getCanvasWidth(canvas); 85 // TODO: why is this needed? otherwise height is all messed up 86 canvas.width = graphWidth; 87 88 if (rangeMin >= rangeMax) { 89 throw new Error(`'rangeMin' should be strictly smaller than 'rangeMax'`); 90 } 91 92 const { format } = props; 93 const ff = createFF(format); 94 95 // const pxPerTick = graphWidth / numTicks / (rangeMax - rangeMin); 96 const ctx = canvas.getContext('2d'); 97 if (!ctx) { 98 throw new Error('Could not get ctx'); 99 } 100 101 const selectedLevel = zoom.mapOrElse( 102 () => 0, 103 (z) => z.i 104 ); 105 const formatter = getFormatter(numTicks, sampleRate, units); 106 const isFocused = focusedNode.isJust; 107 const topLevel = focusedNode.mapOrElse( 108 () => 0, 109 (f) => f.i 110 ); 111 112 const canvasHeight = 113 PX_PER_LEVEL * (levels.length - topLevel) + (isFocused ? BAR_HEIGHT : 0); 114 // const canvasHeight = PX_PER_LEVEL * (levels.length - topLevel); 115 canvas.height = canvasHeight; 116 117 // increase pixel ratio, otherwise it looks bad in high resolution devices 118 if (devicePixelRatio > 1) { 119 canvas.width *= 2; 120 canvas.height *= 2; 121 ctx.scale(2, 2); 122 } 123 124 const { names } = props; 125 // are we focused? 126 // if so, add an initial bar telling it's a collapsed one 127 // TODO clean this up 128 if (isFocused) { 129 const width = numTicks * pxPerTick; 130 ctx.beginPath(); 131 ctx.rect(0, 0, numTicks * pxPerTick, BAR_HEIGHT); 132 // TODO find a neutral color 133 // TODO use getColor ? 134 ctx.fillStyle = colorGreyscale(200, 1).rgb().string(); 135 ctx.fill(); 136 137 // TODO show the samples too? 138 const shortName = focusedNode.mapOrElse( 139 () => 'total', 140 (f) => `total (${f.i - 1} level(s) collapsed)` 141 ); 142 143 // Set the font syle 144 // It's important to set the font BEFORE calculating 'characterSize' 145 // Since it will be used to calculate how many characters can fit 146 ctx.textBaseline = 'middle'; 147 ctx.font = 148 '400 11.5px SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace'; 149 // Since this is a monospaced font any character would do 150 const characterSize = ctx.measureText('a').width; 151 const fitCalc = fitToCanvasRect({ 152 mode: fitMode, 153 charSize: characterSize, 154 rectWidth: width, 155 fullText: shortName, 156 shortText: shortName, 157 }); 158 159 const x = 0; 160 const y = 0; 161 const sh = BAR_HEIGHT; 162 163 ctx.save(); 164 ctx.clip(); 165 ctx.fillStyle = 'black'; 166 const namePosX = Math.round(Math.max(x, 0)); 167 ctx.fillText(fitCalc.text, namePosX + fitCalc.marginLeft, y + sh / 2 + 1); 168 ctx.restore(); 169 } 170 171 for (let i = 0; i < levels.length - topLevel; i += 1) { 172 const level = levels[topLevel + i]; 173 if (!level) { 174 throw new Error(`Could not find level: ${topLevel + i}`); 175 } 176 177 for (let j = 0; j < level.length; j += ff.jStep) { 178 const barIndex = ff.getBarOffset(level, j); 179 const x = tickToX(barIndex); 180 const y = i * PX_PER_LEVEL + (isFocused ? BAR_HEIGHT : 0); 181 182 const sh = BAR_HEIGHT; 183 184 const highlightModeOn = 185 !!props.highlightQuery && props.highlightQuery.length > 0; 186 187 const isHighlighted = nodeIsInQuery( 188 j + ff.jName, 189 level, 190 names, 191 props.highlightQuery 192 ); 193 194 let numBarTicks = ff.getBarTotal(level, j); 195 196 // merge very small blocks into big "collapsed" ones for performance 197 const collapsed = numBarTicks * pxPerTick <= COLLAPSE_THRESHOLD; 198 if (collapsed) { 199 // TODO: refactor this 200 while ( 201 j < level.length - ff.jStep && 202 barIndex + numBarTicks === ff.getBarOffset(level, j + ff.jStep) && 203 ff.getBarTotal(level, j + ff.jStep) * pxPerTick <= 204 COLLAPSE_THRESHOLD && 205 isHighlighted === 206 ((props.highlightQuery && 207 nodeIsInQuery( 208 j + ff.jStep + ff.jName, 209 level, 210 names, 211 props.highlightQuery 212 )) || 213 false) 214 ) { 215 j += ff.jStep; 216 numBarTicks += ff.getBarTotal(level, j); 217 } 218 } 219 220 const sw = numBarTicks * pxPerTick - (collapsed ? 0 : GAP); 221 /*******************************/ 222 /* D r a w R e c t */ 223 /*******************************/ 224 const { spyName } = props; 225 226 const getColor = () => { 227 const common = { 228 level, 229 j, 230 // discount for the levels we skipped 231 // otherwise it will dim out all nodes 232 i: 233 i + 234 focusedNode.mapOrElse( 235 () => 0, 236 (f) => f.i 237 ), 238 names, 239 collapsed, 240 selectedLevel, 241 highlightModeOn, 242 isHighlighted, 243 // keep type narrow https://stackoverflow.com/q/54333982 244 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 245 spyName: spyName as SpyName, 246 palette, 247 }; 248 249 switch (format) { 250 case 'single': { 251 return getColorSingle({ ...common }); 252 } 253 case 'double': { 254 return getColorDouble({ 255 ...common, 256 leftTicks: props.leftTicks, 257 rightTicks: props.rightTicks, 258 }); 259 } 260 default: { 261 throw new Error(`Unsupported format: ${format}`); 262 } 263 } 264 }; 265 266 const color = getColor(); 267 268 ctx.beginPath(); 269 ctx.rect(x, y, sw, sh); 270 ctx.fillStyle = color.string(); 271 ctx.fill(); 272 273 /*******************************/ 274 /* D r a w T e x t */ 275 /*******************************/ 276 // don't write text if there's not enough space for a single letter 277 if (collapsed) { 278 continue; 279 } 280 281 if (sw < LABEL_THRESHOLD) { 282 continue; 283 } 284 285 const shortName = getFunctionName(names, j, format, level); 286 const longName = getLongName( 287 shortName, 288 numBarTicks, 289 numTicks, 290 sampleRate, 291 formatter 292 ); 293 294 // Set the font syle 295 // It's important to set the font BEFORE calculating 'characterSize' 296 // Since it will be used to calculate how many characters can fit 297 ctx.textBaseline = 'middle'; 298 ctx.font = 299 '400 11.5px SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace'; 300 // Since this is a monospaced font any character would do 301 const characterSize = ctx.measureText('a').width; 302 const fitCalc = fitToCanvasRect({ 303 mode: fitMode, 304 charSize: characterSize, 305 rectWidth: sw, 306 fullText: longName, 307 shortText: shortName, 308 }); 309 310 ctx.save(); 311 ctx.clip(); 312 ctx.fillStyle = 'black'; 313 const namePosX = Math.round(Math.max(x, 0)); 314 ctx.fillText(fitCalc.text, namePosX + fitCalc.marginLeft, y + sh / 2 + 1); 315 ctx.restore(); 316 } 317 } 318 } 319 320 function getFunctionName( 321 names: CanvasRendererConfig['names'], 322 j: number, 323 format: CanvasRendererConfig['format'], 324 level: number[] 325 ) { 326 const ff = createFF(format); 327 328 let l = level[j + ff.jName]; 329 if (l === undefined) { 330 l = -1; 331 } 332 const shortName = names[l]; 333 334 if (!shortName) { 335 console.warn('Could not find function name for', { 336 j, 337 format, 338 level, 339 names, 340 }); 341 return ''; 342 } 343 return shortName; 344 } 345 346 function getLongName( 347 shortName: string, 348 numBarTicks: number, 349 numTicks: number, 350 sampleRate: number, 351 formatter: ReturnType<typeof getFormatter> 352 ) { 353 const ratio = numBarTicks / numTicks; 354 const percent = formatPercent(ratio); 355 356 const longName = `${shortName} (${percent}, ${formatter.format( 357 numBarTicks, 358 sampleRate 359 )})`; 360 361 return longName; 362 } 363 364 type getColorCfg = { 365 collapsed: boolean; 366 level: number[]; 367 j: number; 368 selectedLevel: number; 369 i: number; 370 highlightModeOn: boolean; 371 isHighlighted: boolean; 372 names: string[]; 373 spyName: SpyName; 374 palette: FlamegraphPalette; 375 }; 376 377 function getColorCommon({ 378 collapsed, 379 highlightModeOn, 380 isHighlighted, 381 }: Pick< 382 getColorCfg, 383 'selectedLevel' | 'i' | 'collapsed' | 'highlightModeOn' | 'isHighlighted' 384 >) { 385 // Collapsed 386 if (collapsed) { 387 return colorGreyscale(200, 0.66); 388 } 389 390 // We are in a search 391 if (highlightModeOn) { 392 if (!isHighlighted) { 393 return colorGreyscale(200, 0.66); 394 } 395 } 396 397 return null; 398 } 399 400 function getColorSingle(cfg: getColorCfg) { 401 const common = getColorCommon(cfg); 402 403 // common cases, like highlight 404 if (common) { 405 return common; 406 } 407 408 const ff = createFF('single'); 409 410 const a = cfg.selectedLevel > cfg.i ? 0.33 : 1; 411 412 // TODO: clean this up 413 let l = cfg.level[cfg.j + ff.jName]; 414 if (l === undefined) { 415 console.warn('Could nto find level', { 416 l: cfg.j, 417 jName: ff.jName, 418 level: cfg.level, 419 }); 420 l = -1; 421 } 422 const name = cfg.names[l] || ''; 423 const packageName = getPackageNameFromStackTrace(cfg.spyName, name) || ''; 424 425 return colorBasedOnPackageName(cfg.palette, packageName).alpha(a); 426 } 427 428 function getColorDouble( 429 cfg: getColorCfg & { leftTicks: number; rightTicks: number } 430 ) { 431 const common = getColorCommon(cfg); 432 433 // common cases, like highlight 434 if (common) { 435 return common; 436 } 437 438 const a = cfg.selectedLevel > cfg.i ? 0.33 : 1; 439 const { leftRatio, rightRatio } = getRatios( 440 cfg.level, 441 cfg.j, 442 cfg.leftTicks, 443 cfg.rightTicks 444 ); 445 446 const leftPercent = ratioToPercent(leftRatio); 447 const rightPercent = ratioToPercent(rightRatio); 448 449 return colorBasedOnDiffPercent(cfg.palette, leftPercent, rightPercent).alpha( 450 a 451 ); 452 } 453 454 function nodeIsInQuery( 455 index: number, 456 level: number[], 457 names: string[], 458 query: string 459 ) { 460 const l = level[index]; 461 if (!l) { 462 return false; 463 } 464 465 const l2 = names[l]; 466 if (!l2) { 467 return false; 468 } 469 470 return isMatch(query, l2); 471 } 472 473 function getCanvasWidth(canvas: HTMLCanvasElement) { 474 // clientWidth includes padding 475 // however it's not present in node-canvas (used for testing) 476 // so we also fallback to canvas.width 477 return canvas.clientWidth || canvas.width; 478 }