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  }