github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/packages/pyroscope-flamegraph/src/FlameGraph/FlameGraphComponent/index.tsx (about)

     1  /* eslint-disable no-unused-expressions, import/no-extraneous-dependencies */
     2  import React, { useCallback, useRef } from 'react';
     3  import clsx from 'clsx';
     4  import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
     5  import { faRedo } from '@fortawesome/free-solid-svg-icons/faRedo';
     6  import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy';
     7  import { faHighlighter } from '@fortawesome/free-solid-svg-icons/faHighlighter';
     8  import { faCompressAlt } from '@fortawesome/free-solid-svg-icons/faCompressAlt';
     9  import { MenuItem } from '@webapp/ui/Menu';
    10  import useResizeObserver from '@react-hook/resize-observer';
    11  import { Maybe } from 'true-myth';
    12  import debounce from 'lodash.debounce';
    13  import { Flamebearer } from '@pyroscope/models/src';
    14  import styles from './canvas.module.css';
    15  import Flamegraph from './Flamegraph';
    16  import Highlight from './Highlight';
    17  import ContextMenuHighlight from './ContextMenuHighlight';
    18  import FlamegraphTooltip from '../../Tooltip/FlamegraphTooltip';
    19  import ContextMenu from './ContextMenu';
    20  import LogoLink from './LogoLink';
    21  import { SandwichIcon, HeadFirstIcon, TailFirstIcon } from '../../Icons';
    22  import { PX_PER_LEVEL } from './constants';
    23  import Header from './Header';
    24  import { FlamegraphPalette } from './colorPalette';
    25  import type { ViewTypes } from './viewTypes';
    26  import { FitModes, HeadMode, TailMode } from '../../fitMode/fitMode';
    27  import indexStyles from './styles.module.scss';
    28  
    29  interface FlamegraphProps {
    30    flamebearer: Flamebearer;
    31    focusedNode: ConstructorParameters<typeof Flamegraph>[2];
    32    fitMode: ConstructorParameters<typeof Flamegraph>[3];
    33    updateFitMode: (f: FitModes) => void;
    34    highlightQuery: ConstructorParameters<typeof Flamegraph>[4];
    35    zoom: ConstructorParameters<typeof Flamegraph>[5];
    36    showCredit: boolean;
    37    selectedItem: Maybe<string>;
    38  
    39    onZoom: (bar: Maybe<{ i: number; j: number }>) => void;
    40    onFocusOnNode: (i: number, j: number) => void;
    41    setActiveItem: (item: { name: string }) => void;
    42    updateView?: (v: ViewTypes) => void;
    43  
    44    onReset: () => void;
    45    isDirty: () => boolean;
    46  
    47    ['data-testid']?: string;
    48    palette: FlamegraphPalette;
    49    setPalette: (p: FlamegraphPalette) => void;
    50    toolbarVisible?: boolean;
    51    headerVisible?: boolean;
    52    disableClick?: boolean;
    53    showSingleLevel?: boolean;
    54  }
    55  
    56  export default function FlameGraphComponent(props: FlamegraphProps) {
    57    const canvasRef = React.useRef<HTMLCanvasElement>(null);
    58    const flamegraph = useRef<Flamegraph>();
    59  
    60    const [rightClickedNode, setRightClickedNode] = React.useState<
    61      Maybe<{ top: number; left: number; width: number }>
    62    >(Maybe.nothing());
    63  
    64    const {
    65      flamebearer,
    66      focusedNode,
    67      fitMode,
    68      updateFitMode,
    69      highlightQuery,
    70      zoom,
    71      toolbarVisible,
    72      headerVisible = true,
    73      disableClick = false,
    74      showSingleLevel = false,
    75      showCredit,
    76      setActiveItem,
    77      selectedItem,
    78      updateView,
    79    } = props;
    80  
    81    const { onZoom, onReset, isDirty, onFocusOnNode } = props;
    82    const { 'data-testid': dataTestId } = props;
    83    const { palette, setPalette } = props;
    84  
    85    // debounce rendering canvas
    86    // used for situations like resizing
    87    // triggered by eg collapsing the sidebar
    88    const debouncedRenderCanvas = useCallback(
    89      debounce(() => {
    90        renderCanvas();
    91      }, 50),
    92      []
    93    );
    94  
    95    // rerender whenever the canvas size changes
    96    // eg window resize, or simply changing the view
    97    // to display the flamegraph isolated from the table
    98    useResizeObserver(canvasRef, () => {
    99      if (flamegraph) {
   100        debouncedRenderCanvas();
   101      }
   102    });
   103  
   104    const onClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
   105      const opt = getFlamegraph().xyToBar(
   106        e.nativeEvent.offsetX,
   107        e.nativeEvent.offsetY
   108      );
   109  
   110      opt.match({
   111        // clicked on an invalid node
   112        Nothing: () => {},
   113        Just: (bar) => {
   114          zoom.match({
   115            // there's no existing zoom
   116            // so just zoom on the clicked node
   117            Nothing: () => {
   118              onZoom(opt);
   119            },
   120  
   121            // it's already zoomed
   122            Just: (z) => {
   123              // TODO there mya be stale props here...
   124              // we are clicking on the same node that's zoomed
   125              if (bar.i === z.i && bar.j === z.j) {
   126                // undo that zoom
   127                onZoom(Maybe.nothing());
   128              } else {
   129                onZoom(opt);
   130              }
   131            },
   132          });
   133        },
   134      });
   135    };
   136  
   137    const xyToHighlightData = (x: number, y: number) => {
   138      const opt = getFlamegraph().xyToBar(x, y);
   139  
   140      return opt.map((bar) => {
   141        return {
   142          left: getCanvas().offsetLeft + bar.x,
   143          top: getCanvas().offsetTop + bar.y,
   144          width: bar.width,
   145        };
   146      });
   147    };
   148  
   149    const xyToTooltipData = (x: number, y: number) => {
   150      return getFlamegraph().xyToBar(x, y);
   151    };
   152  
   153    const onContextMenuClose = () => {
   154      setRightClickedNode(Maybe.nothing());
   155    };
   156  
   157    const onContextMenuOpen = (x: number, y: number) => {
   158      setRightClickedNode(xyToHighlightData(x, y));
   159    };
   160  
   161    // Context Menu stuff
   162    const xyToContextMenuItems = useCallback(
   163      (x: number, y: number) => {
   164        const dirty = isDirty();
   165        const bar = getFlamegraph().xyToBar(x, y);
   166        const barName = bar.isJust ? bar.value.name : '';
   167  
   168        const CollapseItem = () => {
   169          const hoveredOnValidNode = bar.mapOrElse(
   170            () => false,
   171            () => true
   172          );
   173  
   174          const onClick = bar.mapOrElse(
   175            () => () => {},
   176            (f) => onFocusOnNode.bind(null, f.i, f.j)
   177          );
   178  
   179          return (
   180            <MenuItem
   181              key="focus"
   182              disabled={!hoveredOnValidNode}
   183              onClick={onClick}
   184            >
   185              <FontAwesomeIcon icon={faCompressAlt} />
   186              Collapse nodes above
   187            </MenuItem>
   188          );
   189        };
   190  
   191        const CopyItem = () => {
   192          const onClick = () => {
   193            if (!navigator.clipboard) return;
   194  
   195            navigator.clipboard.writeText(barName);
   196          };
   197  
   198          return (
   199            <MenuItem key="copy" onClick={onClick}>
   200              <FontAwesomeIcon icon={faCopy} />
   201              Copy function name
   202            </MenuItem>
   203          );
   204        };
   205  
   206        const HighlightSimilarNodesItem = () => {
   207          const onClick = () => {
   208            setActiveItem({ name: barName });
   209          };
   210  
   211          const actionName =
   212            selectedItem.isJust && selectedItem.value === barName
   213              ? 'Clear highlight'
   214              : 'Highlight similar nodes';
   215  
   216          return (
   217            <MenuItem key="highlight-similar-nodes" onClick={onClick}>
   218              <FontAwesomeIcon icon={faHighlighter} />
   219              {actionName}
   220            </MenuItem>
   221          );
   222        };
   223  
   224        const OpenInSandwichViewItem = () => {
   225          if (!updateView) {
   226            return null;
   227          }
   228  
   229          const handleClick = () => {
   230            if (updateView) {
   231              updateView('sandwich');
   232              setActiveItem({ name: barName });
   233            }
   234          };
   235  
   236          return (
   237            <MenuItem
   238              key="open-in-sandwich-view"
   239              className={indexStyles.sandwichItem}
   240              onClick={handleClick}
   241            >
   242              <SandwichIcon fill="black" />
   243              Open in sandwich view
   244            </MenuItem>
   245          );
   246        };
   247  
   248        const FitModeItem = () => {
   249          const isHeadFirst = fitMode === HeadMode;
   250  
   251          const handleClick = () => {
   252            const newValues = isHeadFirst ? TailMode : HeadMode;
   253            updateFitMode(newValues);
   254          };
   255  
   256          return (
   257            <MenuItem
   258              className={indexStyles.fitModeItem}
   259              key="fit-mode"
   260              onClick={handleClick}
   261            >
   262              {isHeadFirst ? <TailFirstIcon /> : <HeadFirstIcon />}
   263              Show text {isHeadFirst ? 'tail first' : 'head first'}
   264            </MenuItem>
   265          );
   266        };
   267  
   268        return [
   269          <MenuItem key="reset" disabled={!dirty} onClick={onReset}>
   270            <FontAwesomeIcon icon={faRedo} />
   271            Reset View
   272          </MenuItem>,
   273          CollapseItem(),
   274          CopyItem(),
   275          HighlightSimilarNodesItem(),
   276          OpenInSandwichViewItem(),
   277          FitModeItem(),
   278        ].filter(Boolean) as JSX.Element[];
   279      },
   280      [flamegraph, selectedItem, fitMode]
   281    );
   282  
   283    const constructCanvas = () => {
   284      if (canvasRef.current) {
   285        const f = new Flamegraph(
   286          flamebearer,
   287          canvasRef.current,
   288          focusedNode,
   289          fitMode,
   290          highlightQuery,
   291          zoom,
   292          palette
   293        );
   294  
   295        flamegraph.current = f;
   296      }
   297    };
   298  
   299    React.useEffect(() => {
   300      constructCanvas();
   301      renderCanvas();
   302    }, [palette]);
   303  
   304    React.useEffect(() => {
   305      constructCanvas();
   306      renderCanvas();
   307    }, [
   308      canvasRef.current,
   309      flamebearer,
   310      focusedNode,
   311      fitMode,
   312      highlightQuery,
   313      zoom,
   314    ]);
   315  
   316    const renderCanvas = () => {
   317      canvasRef?.current?.setAttribute('data-state', 'rendering');
   318      flamegraph?.current?.render();
   319      canvasRef?.current?.setAttribute('data-state', 'rendered');
   320    };
   321  
   322    const dataUnavailable =
   323      !flamebearer || (flamebearer && flamebearer.names.length <= 1);
   324  
   325    const getCanvas = () => {
   326      if (!canvasRef.current) {
   327        throw new Error('Missing canvas');
   328      }
   329      return canvasRef.current;
   330    };
   331  
   332    const getFlamegraph = () => {
   333      if (!flamegraph.current) {
   334        throw new Error('Missing canvas');
   335      }
   336      return flamegraph.current;
   337    };
   338  
   339    return (
   340      <div
   341        data-testid="flamegraph-view"
   342        className={clsx(indexStyles.flamegraphPane, {
   343          'vertical-orientation': flamebearer.format === 'double',
   344        })}
   345      >
   346        {headerVisible && (
   347          <Header
   348            format={flamebearer.format}
   349            units={flamebearer.units}
   350            palette={palette}
   351            setPalette={setPalette}
   352            toolbarVisible={toolbarVisible}
   353          />
   354        )}
   355        <div
   356          data-testid={dataTestId}
   357          style={{
   358            opacity: dataUnavailable && !showSingleLevel ? 0 : 1,
   359          }}
   360        >
   361          <canvas
   362            height="0"
   363            data-testid="flamegraph-canvas"
   364            data-highlightquery={highlightQuery}
   365            className={clsx('flamegraph-canvas', styles.canvas)}
   366            ref={canvasRef}
   367            onClick={!disableClick ? onClick : undefined}
   368          />
   369        </div>
   370        {showCredit ? <LogoLink /> : ''}
   371        {flamegraph && canvasRef && (
   372          <Highlight
   373            barHeight={PX_PER_LEVEL}
   374            canvasRef={canvasRef}
   375            zoom={zoom}
   376            xyToHighlightData={xyToHighlightData}
   377          />
   378        )}
   379        {flamegraph && (
   380          <ContextMenuHighlight
   381            barHeight={PX_PER_LEVEL}
   382            node={rightClickedNode}
   383          />
   384        )}
   385        {flamegraph && (
   386          <FlamegraphTooltip
   387            format={flamebearer.format}
   388            canvasRef={canvasRef}
   389            xyToData={xyToTooltipData as ShamefulAny}
   390            numTicks={flamebearer.numTicks}
   391            sampleRate={flamebearer.sampleRate}
   392            leftTicks={
   393              flamebearer.format === 'double' ? flamebearer.leftTicks : 0
   394            }
   395            rightTicks={
   396              flamebearer.format === 'double' ? flamebearer.rightTicks : 0
   397            }
   398            units={flamebearer.units}
   399            palette={palette}
   400          />
   401        )}
   402  
   403        {!disableClick && flamegraph && canvasRef && (
   404          <ContextMenu
   405            canvasRef={canvasRef}
   406            xyToMenuItems={xyToContextMenuItems}
   407            onClose={onContextMenuClose}
   408            onOpen={onContextMenuOpen}
   409          />
   410        )}
   411      </div>
   412    );
   413  }