github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/webapp/javascript/components/Heatmap/index.tsx (about)

     1  import React, { useState, useRef, useMemo, useEffect, RefObject } from 'react';
     2  import useResizeObserver from '@react-hook/resize-observer';
     3  import Color from 'color';
     4  import cl from 'classnames';
     5  import { interpolateViridis } from 'd3-scale-chromatic';
     6  
     7  import { getFormatter } from '@pyroscope/flamegraph/src/format/format';
     8  import type { Heatmap as HeatmapType } from '@webapp/services/render';
     9  import {
    10    SelectedAreaCoordsType,
    11    useHeatmapSelection,
    12  } from './useHeatmapSelection.hook';
    13  import HeatmapTooltip from './HeatmapTooltip';
    14  import { HEATMAP_HEIGHT, HEATMAP_COLORS } from './constants';
    15  import { getTicks } from './utils';
    16  
    17  // eslint-disable-next-line css-modules/no-unused-class
    18  import styles from './Heatmap.module.scss';
    19  
    20  interface HeatmapProps {
    21    heatmap: HeatmapType;
    22    onSelection: (
    23      minV: number,
    24      maxV: number,
    25      startT: number,
    26      endT: number
    27    ) => void;
    28    sampleRate: number;
    29    timezone: string;
    30  }
    31  
    32  export function Heatmap({
    33    heatmap,
    34    onSelection,
    35    sampleRate,
    36    timezone,
    37  }: HeatmapProps) {
    38    const canvasRef = useRef<HTMLCanvasElement>(null);
    39    const heatmapRef = useRef<HTMLDivElement>(null);
    40    const resizedSelectedAreaRef = useRef<HTMLDivElement>(null);
    41    const [heatmapW, setHeatmapW] = useState(0);
    42  
    43    const { selectedCoordinates, selectedAreaToHeatmapRatio, resetSelection } =
    44      useHeatmapSelection({
    45        canvasRef,
    46        resizedSelectedAreaRef,
    47        heatmapW,
    48        heatmap,
    49        onSelection,
    50      });
    51  
    52    useEffect(() => {
    53      if (heatmapRef.current) {
    54        const { width } = heatmapRef.current.getBoundingClientRect();
    55        setHeatmapW(width);
    56      }
    57    }, []);
    58  
    59    useResizeObserver(heatmapRef.current, (entry: ResizeObserverEntry) => {
    60      if (canvasRef.current) {
    61        // Firefox implements `contentBoxSize` as a single content rect, rather than an array
    62        const contentBoxSize = Array.isArray(entry.contentBoxSize)
    63          ? entry.contentBoxSize[0]
    64          : entry.contentBoxSize;
    65  
    66        canvasRef.current.width = contentBoxSize.inlineSize;
    67        setHeatmapW(contentBoxSize.inlineSize);
    68      }
    69    });
    70  
    71    const getColor = useMemo(
    72      () =>
    73        (x: number): string => {
    74          if (x === 0) {
    75            return Color.rgb(22, 22, 22).toString();
    76          }
    77  
    78          // from 0 to 1
    79          const colorIndex = (x - heatmap.minDepth) / heatmap.maxDepth;
    80  
    81          return interpolateViridis(colorIndex);
    82        },
    83      [heatmap]
    84    );
    85  
    86    const getLegendLabel = (index: number): string => {
    87      switch (index) {
    88        case 0:
    89          return heatmap.maxDepth.toString();
    90        case 3:
    91          return Math.round(
    92            (heatmap.maxDepth - heatmap.minDepth) / 2 + heatmap.minDepth
    93          ).toString();
    94        case 6:
    95          return heatmap.minDepth.toString();
    96        default:
    97          return '';
    98      }
    99    };
   100  
   101    const heatmapGrid = (() =>
   102      heatmap.values.map((column, colIndex) => (
   103        // eslint-disable-next-line react/no-array-index-key
   104        <g role="row" key={colIndex}>
   105          {column.map((itemsCount: number, rowIndex: number, itemsCountArr) => (
   106            <rect
   107              role="gridcell"
   108              data-items={itemsCount}
   109              fill={getColor(itemsCount)}
   110              // eslint-disable-next-line react/no-array-index-key
   111              key={rowIndex}
   112              x={colIndex * (heatmapW / heatmap.timeBuckets)}
   113              y={
   114                (itemsCountArr.length - 1 - rowIndex) *
   115                (HEATMAP_HEIGHT / heatmap.valueBuckets)
   116              }
   117              width={heatmapW / heatmap.timeBuckets}
   118              height={HEATMAP_HEIGHT / heatmap.valueBuckets}
   119            />
   120          ))}
   121        </g>
   122      )))();
   123  
   124    return (
   125      <div
   126        ref={heatmapRef}
   127        className={styles.heatmapContainer}
   128        data-testid="heatmap-container"
   129      >
   130        <Axis
   131          axis="y"
   132          sampleRate={sampleRate}
   133          min={heatmap.minValue}
   134          max={heatmap.maxValue}
   135          ticksCount={5}
   136        />
   137        <ResizedSelectedArea
   138          resizedSelectedAreaRef={resizedSelectedAreaRef}
   139          start={selectedCoordinates.start || { x: 0, y: 0 }}
   140          end={selectedCoordinates.end || { x: 0, y: 0 }}
   141          containerW={heatmapW}
   142          resizeRatio={selectedAreaToHeatmapRatio}
   143          handleClick={resetSelection}
   144        />
   145        <svg role="img" className={styles.heatmapSvg} height={HEATMAP_HEIGHT}>
   146          {heatmapGrid}
   147          <foreignObject
   148            className={styles.selectionContainer}
   149            height={HEATMAP_HEIGHT}
   150          >
   151            <canvas
   152              data-testid="selection-canvas"
   153              id="selectionCanvas"
   154              ref={canvasRef}
   155              height={HEATMAP_HEIGHT}
   156            />
   157          </foreignObject>
   158        </svg>
   159        <Axis
   160          axis="x"
   161          min={heatmap.startTime}
   162          max={heatmap.endTime}
   163          ticksCount={7}
   164          timezone={timezone}
   165        />
   166        <div className={styles.legend} data-testid="color-scale">
   167          <span className={styles.units}>Count</span>
   168          {HEATMAP_COLORS.map((color, index) => (
   169            <div key={color.toString()} className={styles.colorLabelContainer}>
   170              {index % 3 === 0 && (
   171                <span role="textbox" className={styles.label}>
   172                  {getLegendLabel(index)}
   173                </span>
   174              )}
   175              <div
   176                className={styles.color}
   177                style={{
   178                  backgroundColor: color.toString(),
   179                }}
   180              />
   181            </div>
   182          ))}
   183        </div>
   184        <HeatmapTooltip
   185          dataSourceElRef={canvasRef}
   186          heatmapW={heatmapW}
   187          heatmap={heatmap}
   188          timezone={timezone}
   189          sampleRate={sampleRate}
   190        />
   191      </div>
   192    );
   193  }
   194  
   195  interface ResizedSelectedArea {
   196    resizedSelectedAreaRef: RefObject<HTMLDivElement>;
   197    containerW: number;
   198    start: SelectedAreaCoordsType;
   199    end: SelectedAreaCoordsType;
   200    resizeRatio: number;
   201    handleClick: () => void;
   202  }
   203  
   204  function ResizedSelectedArea({
   205    resizedSelectedAreaRef,
   206    containerW,
   207    start,
   208    end,
   209    resizeRatio,
   210    handleClick,
   211  }: ResizedSelectedArea) {
   212    const top = start.y > end.y ? end.y : start.y;
   213    const originalLeftOffset = start.x > end.x ? end.x : start.x;
   214  
   215    const w = Math.abs(containerW / resizeRatio);
   216    const h = Math.abs(end.y - start.y);
   217    const left = Math.abs((originalLeftOffset * w) / (end.x - start.x || 1));
   218  
   219    return (
   220      <>
   221        {h ? (
   222          <div
   223            style={{
   224              position: 'absolute',
   225              width: w,
   226              height: h,
   227              top,
   228              left,
   229              border: `1px solid ${Color.rgb(255, 149, 5).toString()}`,
   230            }}
   231          />
   232        ) : null}
   233        <div
   234          ref={resizedSelectedAreaRef}
   235          data-testid="selection-resizable-canvas"
   236          onClick={handleClick}
   237          className={styles.selectedAreaBlock}
   238          id="selectionArea"
   239          style={{
   240            width: w,
   241            height: h,
   242            top,
   243            left,
   244            mixBlendMode: 'overlay',
   245            backgroundColor: Color.rgb(255, 149, 5).toString(),
   246          }}
   247        />
   248      </>
   249    );
   250  }
   251  
   252  interface AxisProps {
   253    axis: 'x' | 'y';
   254    min: number;
   255    max: number;
   256    ticksCount: number;
   257    timezone?: string;
   258    sampleRate?: number;
   259  }
   260  
   261  function Axis({ axis, max, min, ticksCount, timezone, sampleRate }: AxisProps) {
   262    const yAxisformatter = sampleRate && getFormatter(max, sampleRate, 'samples');
   263    let ticks: string[];
   264  
   265    ticks = getTicks(
   266      min,
   267      max,
   268      { timezone, formatter: yAxisformatter, ticksCount },
   269      sampleRate
   270    );
   271  
   272    // There's not enough data to construct the Y axis
   273    if (axis === 'y' && min === 0 && max === 0) {
   274      ticks = ['0'];
   275    }
   276  
   277    return (
   278      <div
   279        data-testid={`${axis}-axis`}
   280        className={styles[`${axis}Axis`]}
   281        style={axis === 'y' ? { height: HEATMAP_HEIGHT } : {}}
   282      >
   283        {yAxisformatter ? (
   284          <div className={styles.axisUnits}>{yAxisformatter.suffix}s</div>
   285        ) : null}
   286        <div className={styles.tickValues}>
   287          {ticks.map((tick) => (
   288            <div
   289              role="textbox"
   290              className={cl(styles.tickValue, styles[`${axis}TickValue`])}
   291              key={tick}
   292            >
   293              <span>{tick}</span>
   294            </div>
   295          ))}
   296        </div>
   297        <div className={styles.ticksContainer}>
   298          {ticks.map((tick) => (
   299            <div className={styles.tick} key={tick} />
   300          ))}
   301        </div>
   302      </div>
   303    );
   304  }