github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/packages/pyroscope-flamegraph/src/FlameGraph/FlameGraphComponent/Flamegraph.ts (about) 1 import { DeepReadonly } from 'ts-essentials'; 2 import { Maybe } from 'true-myth'; 3 import { 4 createFF, 5 Flamebearer, 6 singleFF, 7 doubleFF, 8 SpyName, 9 } from '@pyroscope/models/src'; 10 import type { Units } from '@pyroscope/models/src'; 11 import { PX_PER_LEVEL, BAR_HEIGHT, COLLAPSE_THRESHOLD } from './constants'; 12 import type { FlamegraphPalette } from './colorPalette'; 13 // there's a dependency cycle here but it should be fine 14 /* eslint-disable-next-line import/no-cycle */ 15 import RenderCanvas from './Flamegraph_render'; 16 17 /* eslint-disable no-useless-constructor */ 18 19 /* 20 * Branded Type to distinguish between x,y that were validated to be within bounds or not. 21 */ 22 type XYWithinBounds = { x: number; y: number } & { __brand: 'XYWithinBounds' }; 23 24 export default class Flamegraph { 25 private ff: ReturnType<typeof createFF>; 26 27 constructor( 28 private readonly flamebearer: Flamebearer, 29 private canvas: HTMLCanvasElement, 30 /** 31 * What node to be 'focused' 32 * ie what node to start the tree 33 */ 34 private focusedNode: Maybe<DeepReadonly<{ i: number; j: number }>>, 35 /** 36 * What level has been "selected" 37 * All nodes above will be dimmed out 38 */ 39 // private selectedLevel: number, 40 private readonly fitMode: 'HEAD' | 'TAIL', 41 /** 42 * The query used to match against the node name. 43 * For each node, 44 * if it matches it will be highlighted, 45 * otherwise it will be greyish. 46 */ 47 private readonly highlightQuery: string, 48 private zoom: Maybe<DeepReadonly<{ i: number; j: number }>>, 49 50 private palette: FlamegraphPalette 51 ) { 52 // TODO 53 // these were only added because storybook is not setting 54 // the property to the component 55 this.zoom = zoom; 56 this.focusedNode = focusedNode; 57 this.flamebearer = flamebearer; 58 this.canvas = canvas; 59 this.highlightQuery = highlightQuery; 60 this.ff = createFF(flamebearer.format); 61 this.palette = palette; 62 63 // don't allow to have a zoom smaller than the focus 64 // since it does not make sense 65 if (focusedNode.isJust && zoom.isJust) { 66 if (zoom.value.i < focusedNode.value.i) { 67 throw new Error('Zoom i level should be bigger than Focus'); 68 } 69 } 70 } 71 72 public render() { 73 const { rangeMin, rangeMax } = this.getRange(); 74 75 const props = { 76 canvas: this.canvas, 77 78 format: this.flamebearer.format, 79 numTicks: this.flamebearer.numTicks, 80 sampleRate: this.flamebearer.sampleRate, 81 names: this.flamebearer.names, 82 levels: this.flamebearer.levels, 83 // keep type narrow https://stackoverflow.com/q/54333982 84 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 85 spyName: this.flamebearer.spyName as SpyName, 86 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 87 units: this.flamebearer.units as Units, 88 maxSelf: this.flamebearer.maxSelf, 89 90 rangeMin, 91 rangeMax, 92 fitMode: this.fitMode, 93 highlightQuery: this.highlightQuery, 94 zoom: this.zoom, 95 focusedNode: this.focusedNode, 96 pxPerTick: this.pxPerTick(), 97 tickToX: this.tickToX, 98 palette: this.palette, 99 }; 100 101 const { format: viewType } = this.flamebearer; 102 103 switch (viewType) { 104 case 'single': { 105 RenderCanvas({ ...props, format: 'single' }); 106 break; 107 } 108 case 'double': { 109 RenderCanvas({ 110 ...props, 111 leftTicks: this.flamebearer.leftTicks, 112 rightTicks: this.flamebearer.rightTicks, 113 }); 114 break; 115 } 116 default: { 117 throw new Error(`Invalid format: '${viewType}'`); 118 } 119 } 120 } 121 122 private pxPerTick() { 123 const { rangeMin, rangeMax } = this.getRange(); 124 // const graphWidth = this.canvas.width; 125 const graphWidth = this.getCanvasWidth(); 126 127 return graphWidth / this.flamebearer.numTicks / (rangeMax - rangeMin); 128 } 129 130 private tickToX = (i: number) => { 131 const { rangeMin } = this.getRange(); 132 return (i - this.flamebearer.numTicks * rangeMin) * this.pxPerTick(); 133 }; 134 135 private getRange() { 136 const { ff } = this; 137 138 // delay calculation since they may not be set 139 const calculatedZoomRange = (zoom: { i: number; j: number }) => { 140 const level = this.flamebearer.levels[zoom.i]; 141 if (!level) { 142 throw new Error(`Could not find level: '${zoom.i}'`); 143 } 144 145 const zoomMin = 146 ff.getBarOffset(level, zoom.j) / this.flamebearer.numTicks; 147 const zoomMax = 148 (ff.getBarOffset(level, zoom.j) + ff.getBarTotal(level, zoom.j)) / 149 this.flamebearer.numTicks; 150 151 return { 152 rangeMin: zoomMin, 153 rangeMax: zoomMax, 154 }; 155 }; 156 157 const calculatedFocusRange = (focusedNode: { i: number; j: number }) => { 158 const level = this.flamebearer.levels[focusedNode.i]; 159 160 if (!level) { 161 throw new Error(`Could not find level: '${focusedNode.i}'`); 162 } 163 const focusMin = 164 ff.getBarOffset(level, focusedNode.j) / this.flamebearer.numTicks; 165 166 const focusMax = 167 (ff.getBarOffset(level, focusedNode.j) + 168 ff.getBarTotal(level, focusedNode.j)) / 169 this.flamebearer.numTicks; 170 171 return { 172 rangeMin: focusMin, 173 rangeMax: focusMax, 174 }; 175 }; 176 177 const { zoom, focusedNode } = this; 178 179 return zoom.match({ 180 Just: (z) => { 181 return focusedNode.match({ 182 // both are set 183 Just: (f) => { 184 const fRange = calculatedFocusRange(f); 185 const zRange = calculatedZoomRange(z); 186 187 // focus is smaller, let's use it 188 if ( 189 fRange.rangeMax - fRange.rangeMin < 190 zRange.rangeMax - zRange.rangeMin 191 ) { 192 console.warn( 193 'Focus is smaller than range, this shouldnt happen. Verify that the zoom is always bigger than the focus.' 194 ); 195 return calculatedFocusRange(f); 196 } 197 198 return calculatedZoomRange(z); 199 }, 200 201 // only zoom is set 202 Nothing: () => { 203 return calculatedZoomRange(z); 204 }, 205 }); 206 }, 207 208 Nothing: () => { 209 return focusedNode.match({ 210 Just: (f) => { 211 // only focus is set 212 return calculatedFocusRange(f); 213 }, 214 Nothing: () => { 215 // neither are set 216 return { 217 rangeMin: 0, 218 rangeMax: 1, 219 }; 220 }, 221 }); 222 }, 223 }); 224 } 225 226 private getCanvasWidth() { 227 // bit of a hack, but clientWidth is not available in node-canvas 228 return this.canvas.clientWidth || this.canvas.width; 229 } 230 231 private isFocused() { 232 return this.focusedNode.isJust; 233 } 234 235 // binary search of a block in a stack level 236 // TODO(eh-am): calculations seem wrong when x is 0 and y != 0, 237 // also on the border 238 private binarySearchLevel(x: number, level: number[]) { 239 const { ff } = this; 240 241 let i = 0; 242 let j = level.length - ff.jStep; 243 244 while (i <= j) { 245 /* eslint-disable-next-line no-bitwise */ 246 const m = ff.jStep * ((i / ff.jStep + j / ff.jStep) >> 1); 247 const x0 = this.tickToX(ff.getBarOffset(level, m)); 248 const x1 = this.tickToX( 249 ff.getBarOffset(level, m) + ff.getBarTotal(level, m) 250 ); 251 252 if (x0 <= x && x1 >= x) { 253 return x1 - x0 > COLLAPSE_THRESHOLD ? m : -1; 254 } 255 if (x0 > x) { 256 j = m - ff.jStep; 257 } else { 258 i = m + ff.jStep; 259 } 260 } 261 return -1; 262 } 263 264 private xyToBarIndex(x: number, y: number) { 265 if (x < 0 || y < 0) { 266 throw new Error(`x and y must be bigger than 0. x = ${x}, y = ${y}`); 267 } 268 269 // clicked on the top bar and it's focused 270 if (this.isFocused() && y <= BAR_HEIGHT) { 271 return { i: 0, j: 0 }; 272 } 273 274 // in focused mode there's a "fake" bar at the top 275 // so we must discount for it 276 const computedY = this.isFocused() ? y - BAR_HEIGHT : y; 277 278 const compensatedFocusedY = this.focusedNode.mapOrElse( 279 () => 0, 280 (node) => { 281 return node.i <= 0 ? 0 : node.i; 282 } 283 ); 284 285 const compensation = this.zoom.match({ 286 Just: () => { 287 return this.focusedNode.match({ 288 Just: () => { 289 // both are set, prefer focus 290 return compensatedFocusedY; 291 }, 292 293 Nothing: () => { 294 // only zoom is set 295 return 0; 296 }, 297 }); 298 }, 299 300 Nothing: () => { 301 return this.focusedNode.match({ 302 Just: () => { 303 // only focus is set 304 return compensatedFocusedY; 305 }, 306 307 Nothing: () => { 308 // none of them are set 309 return 0; 310 }, 311 }); 312 }, 313 }); 314 315 const i = Math.floor(computedY / PX_PER_LEVEL) + compensation; 316 317 if (i >= 0 && i < this.flamebearer.levels.length) { 318 const level = this.flamebearer.levels[i]; 319 if (!level) { 320 throw new Error(`Could not find level: '${i}'`); 321 } 322 323 const j = this.binarySearchLevel(x, level); 324 325 return { i, j }; 326 } 327 328 return { i: 0, j: 0 }; 329 } 330 331 private parseXY(x: number, y: number) { 332 const withinBounds = this.isWithinBounds(x, y); 333 334 const v = { x, y } as XYWithinBounds; 335 336 if (withinBounds) { 337 return Maybe.of(v); 338 } 339 340 return Maybe.nothing<typeof v>(); 341 } 342 343 private xyToBarPosition = (xy: XYWithinBounds) => { 344 const { ff } = this; 345 const { i, j } = this.xyToBarIndex(xy.x, xy.y); 346 347 const topLevel = this.focusedNode.mapOrElse( 348 () => 0, 349 (node) => (node.i < 0 ? 0 : node.i - 1) 350 ); 351 352 const level = this.flamebearer.levels[i]; 353 if (!level) { 354 throw new Error(`Could not find level: '${i}'`); 355 } 356 const posX = Math.max(this.tickToX(ff.getBarOffset(level, j)), 0); 357 358 // lower bound is 0 359 const posY = Math.max((i - topLevel) * PX_PER_LEVEL, 0); 360 361 const sw = Math.min( 362 this.tickToX(ff.getBarOffset(level, j) + ff.getBarTotal(level, j)) - posX, 363 this.getCanvasWidth() 364 ); 365 366 return { 367 x: posX, 368 y: posY, 369 width: sw, 370 }; 371 }; 372 373 private xyToBarData = (xy: XYWithinBounds) => { 374 const { i, j } = this.xyToBarIndex(xy.x, xy.y); 375 const level = this.flamebearer.levels[i]; 376 if (!level) { 377 throw new Error(`Could not find level: '${i}'`); 378 } 379 380 switch (this.flamebearer.format) { 381 case 'single': { 382 const ff = singleFF; 383 384 return { 385 format: 'single' as const, 386 name: this.flamebearer.names[ff.getBarName(level, j)], 387 self: ff.getBarSelf(level, j), 388 offset: ff.getBarOffset(level, j), 389 total: ff.getBarTotal(level, j), 390 }; 391 } 392 case 'double': { 393 const ff = doubleFF; 394 395 return { 396 format: 'double' as const, 397 barTotal: ff.getBarTotal(level, j), 398 totalLeft: ff.getBarTotalLeft(level, j), 399 totalRight: ff.getBarTotalRght(level, j), 400 totalDiff: ff.getBarTotalDiff(level, j), 401 name: this.flamebearer.names[ff.getBarName(level, j)], 402 }; 403 } 404 405 default: { 406 throw new Error(`Unsupported type`); 407 } 408 } 409 }; 410 411 public isWithinBounds = (x: number, y: number) => { 412 if (x < 0 || x > this.getCanvasWidth()) { 413 return false; 414 } 415 416 try { 417 const { i, j } = this.xyToBarIndex(x, y); 418 if (j === -1 || i === -1) { 419 return false; 420 } 421 } catch (e) { 422 return false; 423 } 424 425 return true; 426 }; 427 428 /* 429 * Given x and y coordinates 430 * return all information about the bar under those coordinates 431 */ 432 public xyToBar(x: number, y: number) { 433 return this.parseXY(x, y).map((xyWithinBounds) => { 434 const { i, j } = this.xyToBarIndex(x, y); 435 const position = this.xyToBarPosition(xyWithinBounds); 436 const data = this.xyToBarData(xyWithinBounds); 437 438 return { 439 i, 440 j, 441 ...position, 442 ...data, 443 }; 444 }); 445 } 446 }