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  }