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

     1  import React, {
     2    CSSProperties,
     3    RefObject,
     4    ReactNode,
     5    useEffect,
     6    useState,
     7    useRef,
     8    useCallback,
     9    Dispatch,
    10    SetStateAction,
    11  } from 'react';
    12  import clsx from 'clsx';
    13  import type { Units } from '@pyroscope/models/src';
    14  
    15  import RightClickIcon from './RightClickIcon';
    16  import LeftClickIcon from './LeftClickIcon';
    17  
    18  import styles from './Tooltip.module.scss';
    19  
    20  export type TooltipData = {
    21    units: Units;
    22    percent?: string | number;
    23    samples?: string;
    24    formattedValue?: string;
    25    self?: string;
    26    total?: string;
    27    tooltipType: 'table' | 'flamegraph';
    28  };
    29  
    30  export interface TooltipProps {
    31    // canvas or table body ref
    32    dataSourceRef: RefObject<HTMLCanvasElement | HTMLTableSectionElement>;
    33  
    34    shouldShowFooter?: boolean;
    35    shouldShowTitle?: boolean;
    36    clickInfoSide?: 'left' | 'right';
    37  
    38    setTooltipContent: (
    39      setContent: Dispatch<
    40        SetStateAction<{
    41          title: {
    42            text: string;
    43            diff: {
    44              text: string;
    45              color: string;
    46            };
    47          };
    48          tooltipData: TooltipData[];
    49        }>
    50      >,
    51      onMouseOut: () => void,
    52      e: MouseEvent
    53    ) => void;
    54  }
    55  
    56  export function Tooltip({
    57    shouldShowFooter = true,
    58    shouldShowTitle = true,
    59    dataSourceRef,
    60    clickInfoSide,
    61    setTooltipContent,
    62  }: TooltipProps) {
    63    const tooltipRef = useRef<HTMLDivElement>(null);
    64    const [content, setContent] = React.useState({
    65      title: {
    66        text: '',
    67        diff: {
    68          text: '',
    69          color: '',
    70        },
    71      },
    72      tooltipData: [] as TooltipData[],
    73    });
    74    const [style, setStyle] = useState<CSSProperties>();
    75  
    76    const onMouseOut = () => {
    77      setStyle({
    78        visibility: 'hidden',
    79      });
    80      setContent({
    81        title: {
    82          text: '',
    83          diff: {
    84            text: '',
    85            color: '',
    86          },
    87        },
    88        tooltipData: [],
    89      });
    90    };
    91  
    92    const memoizedOnMouseMove = useCallback(
    93      (e: MouseEvent) => {
    94        if (!tooltipRef || !tooltipRef.current) {
    95          throw new Error('Missing tooltipElement');
    96        }
    97  
    98        const left = Math.min(
    99          e.clientX + 12,
   100          window.innerWidth - tooltipRef.current.clientWidth - 20
   101        );
   102        const top = e.clientY + 20;
   103  
   104        const style: React.CSSProperties = {
   105          top,
   106          left,
   107          visibility: 'visible',
   108        };
   109  
   110        setTooltipContent(setContent, onMouseOut, e);
   111        setStyle(style);
   112      },
   113  
   114      // these are the dependencies from props
   115      // that are going to be used in onMouseMove
   116      [tooltipRef, setTooltipContent]
   117    );
   118  
   119    useEffect(() => {
   120      // use closure to "cache" the current dataSourceRef(canvas/table) reference
   121      // so that when cleaning up, it points to a valid canvas
   122      // (otherwise it would be null)
   123      const dataSourceEl = dataSourceRef.current;
   124      if (!dataSourceEl) {
   125        return () => {};
   126      }
   127  
   128      // watch for mouse events on the bar
   129      dataSourceEl.addEventListener(
   130        'mousemove',
   131        memoizedOnMouseMove as EventListener
   132      );
   133      dataSourceEl.addEventListener('mouseout', onMouseOut);
   134  
   135      return () => {
   136        dataSourceEl.removeEventListener(
   137          'mousemove',
   138          memoizedOnMouseMove as EventListener
   139        );
   140        dataSourceEl.removeEventListener('mouseout', onMouseOut);
   141      };
   142    }, [dataSourceRef.current, memoizedOnMouseMove]);
   143  
   144    return (
   145      <div
   146        data-testid="tooltip"
   147        className={clsx(styles.tooltip, {
   148          [styles.flamegraphDiffTooltip]: content.tooltipData.length > 1,
   149        })}
   150        style={style}
   151        ref={tooltipRef}
   152      >
   153        {content.tooltipData.length > 0 && (
   154          <>
   155            {shouldShowTitle && (
   156              <div className={styles.tooltipName} data-testid="tooltip-title">
   157                {content.title.text}
   158              </div>
   159            )}
   160            <div
   161              className={styles.functionName}
   162              data-testid="tooltip-function-name"
   163            >
   164              {content.title.text}
   165            </div>
   166            {content.title.diff.text.length > 0 ? (
   167              <TooltipTable
   168                data={content.tooltipData}
   169                diff={content.title.diff}
   170              />
   171            ) : (
   172              <TooltipTable data={content.tooltipData} />
   173            )}
   174            {shouldShowFooter && <TooltipFooter clickInfoSide={clickInfoSide} />}
   175          </>
   176        )}
   177      </div>
   178    );
   179  }
   180  
   181  const tooltipTitles: Record<
   182    Units,
   183    { percent: string; formattedValue: string; total: string }
   184  > = {
   185    objects: {
   186      percent: '% of objects in RAM',
   187      formattedValue: 'Objects in RAM',
   188      total: '% of total RAM',
   189    },
   190    goroutines: {
   191      percent: '% of goroutines',
   192      formattedValue: 'Goroutines',
   193      total: '% of total goroutines',
   194    },
   195    bytes: {
   196      percent: '% of RAM',
   197      formattedValue: 'RAM',
   198      total: '% of total bytes',
   199    },
   200    samples: {
   201      percent: 'Share of CPU',
   202      formattedValue: 'CPU Time',
   203      total: '% of total CPU',
   204    },
   205    lock_nanoseconds: {
   206      percent: '% of Time spent',
   207      formattedValue: 'Time',
   208      total: '% of total seconds',
   209    },
   210    lock_samples: {
   211      percent: '% of contended locks',
   212      formattedValue: 'Contended locks',
   213      total: '% of total locks',
   214    },
   215    trace_samples: {
   216      percent: '% of time',
   217      formattedValue: 'Samples',
   218      total: '% of total samples',
   219    },
   220    exceptions: {
   221      percent: '% of thrown exceptions',
   222      formattedValue: 'Thrown exceptions',
   223      total: '% of total thrown exceptions',
   224    },
   225    unknown: {
   226      percent: 'Percentage',
   227      formattedValue: 'Units',
   228      total: '% of total units',
   229    },
   230  };
   231  
   232  function TooltipTable({
   233    data,
   234    diff,
   235  }: {
   236    data: TooltipData[];
   237    diff?: { text: string; color: string };
   238  }) {
   239    const [baselineData, comparisonData] = data;
   240  
   241    if (!baselineData) {
   242      return null;
   243    }
   244  
   245    let renderTable: () => ReactNode;
   246  
   247    switch (baselineData.tooltipType) {
   248      case 'flamegraph':
   249        renderTable = () => (
   250          <>
   251            {comparisonData && (
   252              <thead>
   253                <tr>
   254                  <th />
   255                  <th>Baseline</th>
   256                  <th>Comparison</th>
   257                  <th>Diff</th>
   258                </tr>
   259              </thead>
   260            )}
   261            <tbody>
   262              <tr>
   263                <td>{tooltipTitles[baselineData.units].percent}:</td>
   264                <td>{baselineData.percent}</td>
   265                {comparisonData && (
   266                  <>
   267                    <td>{comparisonData.percent}</td>
   268                    <td>
   269                      {diff && (
   270                        <span
   271                          data-testid="tooltip-diff"
   272                          style={{ color: diff.color }}
   273                        >
   274                          {diff.text}
   275                        </span>
   276                      )}
   277                    </td>
   278                  </>
   279                )}
   280              </tr>
   281              <tr>
   282                <td>{tooltipTitles[baselineData.units].formattedValue}:</td>
   283                <td>{baselineData.formattedValue}</td>
   284                {comparisonData && (
   285                  <>
   286                    <td>{comparisonData.formattedValue}</td>
   287                    <td />
   288                  </>
   289                )}
   290              </tr>
   291              <tr>
   292                <td>Samples:</td>
   293                <td>{baselineData.samples}</td>
   294                {comparisonData && (
   295                  <>
   296                    <td>{comparisonData.samples}</td>
   297                    <td />
   298                  </>
   299                )}
   300              </tr>
   301            </tbody>
   302          </>
   303        );
   304        break;
   305      case 'table':
   306        renderTable = () => (
   307          <>
   308            <thead>
   309              <tr>
   310                <td />
   311                <td>Self ({tooltipTitles[baselineData.units].total})</td>
   312                <td>Total ({tooltipTitles[baselineData.units].total})</td>
   313              </tr>
   314            </thead>
   315            <tbody>
   316              <tr>
   317                <td>{tooltipTitles[baselineData.units].formattedValue}:</td>
   318                <td>{baselineData.self}</td>
   319                <td>{baselineData.total}</td>
   320              </tr>
   321            </tbody>
   322          </>
   323        );
   324        break;
   325      default:
   326        renderTable = () => null;
   327    }
   328  
   329    return (
   330      <table
   331        data-testid="tooltip-table"
   332        className={clsx(styles.tooltipTable, {
   333          [styles[`${baselineData.tooltipType}${comparisonData ? 'Diff' : ''}`]]:
   334            baselineData.tooltipType,
   335        })}
   336      >
   337        {renderTable()}
   338      </table>
   339    );
   340  }
   341  
   342  function TooltipFooter({
   343    clickInfoSide,
   344  }: {
   345    clickInfoSide?: 'left' | 'right';
   346  }) {
   347    let clickInfo: ReactNode;
   348  
   349    switch (clickInfoSide) {
   350      case 'right':
   351        clickInfo = (
   352          <>
   353            <RightClickIcon />
   354            <span>Right click for more node viewing options</span>
   355          </>
   356        );
   357        break;
   358      case 'left':
   359        clickInfo = (
   360          <>
   361            <LeftClickIcon />
   362            <span>Click to highlight node in flamegraph</span>
   363          </>
   364        );
   365        break;
   366      default:
   367        clickInfo = <></>;
   368    }
   369  
   370    return (
   371      <div data-testid="tooltip-footer" className={styles.clickInfo}>
   372        {clickInfo}
   373      </div>
   374    );
   375  }