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

     1  /* eslint-disable react/no-access-state-in-setstate */
     2  /* eslint-disable react/no-did-update-set-state */
     3  /* eslint-disable react/destructuring-assignment */
     4  import React, { ReactNode } from 'react';
     5  import Color from 'color';
     6  import type { Group } from '@pyroscope/models/src';
     7  import type { Timeline } from '@webapp/models/timeline';
     8  import { Annotation } from '@webapp/models/annotation';
     9  import Legend from '@webapp/pages/tagExplorer/components/Legend';
    10  import type { TooltipCallbackProps } from '@webapp/components/TimelineChart/Tooltip.plugin';
    11  import TooltipWrapper from '@webapp/components/TimelineChart/TooltipWrapper';
    12  import type { ITooltipWrapperProps } from '@webapp/components/TimelineChart/TooltipWrapper';
    13  import TimelineChart from '@webapp/components/TimelineChart/TimelineChart';
    14  import { ContextMenuProps } from '@webapp/components/TimelineChart/ContextMenu.plugin';
    15  import {
    16    markingsFromSelection,
    17    ANNOTATION_COLOR,
    18  } from '@webapp/components/TimelineChart/markings';
    19  import { centerTimelineData } from '@webapp/components/TimelineChart/centerTimelineData';
    20  import styles from './TimelineChartWrapper.module.css';
    21  
    22  export interface TimelineGroupData {
    23    data: Group;
    24    tagName: string;
    25    color?: Color;
    26  }
    27  
    28  export interface TimelineData {
    29    data?: Timeline;
    30    color?: string;
    31  }
    32  
    33  interface Selection {
    34    from: string;
    35    to: string;
    36    color: Color;
    37    overlayColor: Color;
    38  }
    39  
    40  type SingleDataProps = {
    41    /** used to display at max 2 time series */
    42    mode: 'singles';
    43    /** timelineA refers to the first (and maybe unique) timeline */
    44    timelineA: TimelineData;
    45    /** timelineB refers to the second timeline, useful for comparison view */
    46    timelineB?: TimelineData;
    47  };
    48  
    49  // Used in Tag Explorer
    50  type MultipleDataProps = {
    51    /** used when displaying multiple time series. original use case is for tag explorer */
    52    mode: 'multiple';
    53    /** timelineGroups refers to group of timelines, useful for explore view */
    54    timelineGroups: TimelineGroupData[];
    55    /** if there is active group, the other groups should "dim" themselves */
    56    activeGroup: string;
    57    /** show or hide legend */
    58    showTagsLegend: boolean;
    59    /** to set active tagValue using <Legend /> */
    60    handleGroupByTagValueChange: (groupByTagValue: string) => void;
    61  };
    62  
    63  type TimelineDataProps = SingleDataProps | MultipleDataProps;
    64  
    65  type TimelineChartWrapperProps = TimelineDataProps & {
    66    /** the id attribute of the element float will use to apply to, it should be globally unique */
    67    id: string;
    68  
    69    ['data-testid']?: string;
    70    onSelect: (from: string, until: string) => void;
    71    format: 'lines' | 'bars';
    72  
    73    height?: string;
    74  
    75    /** refers to the highlighted selection */
    76    selection?: {
    77      left?: Selection;
    78      right?: Selection;
    79    };
    80  
    81    timezone: 'browser' | 'utc';
    82    title?: ReactNode;
    83  
    84    /** whether to show a selection with grabbable handle
    85     * ATTENTION: it only works with a single selection */
    86    selectionWithHandler?: boolean;
    87  
    88    /** selection type 'single' => gray selection, 'double' => color selection */
    89    selectionType: 'single' | 'double';
    90    onHoverDisplayTooltip?: React.FC<TooltipCallbackProps>;
    91  
    92    /** list of annotations timestamp, to be rendered as markings */
    93    annotations?: Annotation[];
    94  
    95    /** What element to render when clicking */
    96    ContextMenu?: (props: ContextMenuProps) => React.ReactNode;
    97  
    98    /** The list of timeline IDs (flotjs component) to sync the crosshair with */
    99    syncCrosshairsWith?: string[];
   100  };
   101  
   102  class TimelineChartWrapper extends React.Component<
   103    TimelineChartWrapperProps,
   104    // TODO add type
   105    ShamefulAny
   106  > {
   107    // eslint-disable-next-line react/static-property-placement
   108    static defaultProps = {
   109      format: 'bars',
   110      mode: 'singles',
   111      timezone: 'browser',
   112      height: '100px',
   113    };
   114  
   115    constructor(props: TimelineChartWrapperProps) {
   116      super(props);
   117  
   118      let flotOptions = {
   119        margin: {
   120          top: 0,
   121          left: 0,
   122          bottom: 0,
   123          right: 0,
   124        },
   125        selection: {
   126          selectionWithHandler: props.selectionWithHandler || false,
   127          mode: 'x',
   128          // custom selection works for 'single' selection type,
   129          // 'double' selection works in old fashion way
   130          // we use different props to customize selection appearance
   131          selectionType: props.selectionType,
   132          overlayColor:
   133            props.selectionType === 'double'
   134              ? undefined
   135              : props?.selection?.['right']?.overlayColor ||
   136                props?.selection?.['left']?.overlayColor,
   137          boundaryColor:
   138            props.selectionType === 'double'
   139              ? undefined
   140              : props?.selection?.['right']?.color ||
   141                props?.selection?.['left']?.color,
   142        },
   143        crosshair: {
   144          mode: 'x',
   145          color: '#C3170D',
   146          lineWidth: '1',
   147        },
   148        grid: {
   149          borderWidth: 1, // outside border of the timelines
   150          hoverable: true,
   151  
   152          // For the contextMenu plugin to work. From the docs:
   153          // > If you set “clickable” to true, the plot will listen for click events
   154          //   on the plot area and fire a “plotclick” event on the placeholder with
   155          //   a position and a nearby data item object as parameters.
   156          clickable: true,
   157        },
   158        annotations: [],
   159        syncCrosshairsWith: [],
   160        yaxis: {
   161          show: false,
   162          min: 0,
   163        },
   164        points: {
   165          show: false,
   166          symbol: () => {}, // function that draw points on the chart
   167        },
   168        lines: {
   169          show: false,
   170        },
   171        bars: {
   172          show: true,
   173        },
   174        xaxis: {
   175          mode: 'time',
   176          timezone: props.timezone,
   177          reserveSpace: false,
   178          // according to https://github.com/flot/flot/blob/master/API.md#customizing-the-axes
   179          minTickSize: [3, 'second'],
   180        },
   181      };
   182  
   183      flotOptions = (() => {
   184        switch (props.format) {
   185          case 'lines': {
   186            return {
   187              ...flotOptions,
   188              lines: {
   189                show: true,
   190                lineWidth: 0.8,
   191              },
   192              bars: {
   193                show: false,
   194              },
   195            };
   196          }
   197  
   198          case 'bars': {
   199            return {
   200              ...flotOptions,
   201              bars: {
   202                show: true,
   203              },
   204              lines: {
   205                show: false,
   206              },
   207            };
   208          }
   209          default: {
   210            throw new Error(`Invalid format: '${props.format}'`);
   211          }
   212        }
   213      })();
   214  
   215      this.state = { flotOptions };
   216      this.state.flotOptions.grid.markings = this.plotMarkings();
   217      this.state.flotOptions.annotations = this.composeAnnotationsList();
   218    }
   219  
   220    // TODO: this only seems to sync props back into the state, which seems unnecessary
   221    componentDidUpdate(prevProps: TimelineChartWrapperProps) {
   222      if (
   223        prevProps.selection !== this.props.selection ||
   224        prevProps.annotations !== this.props.annotations ||
   225        prevProps.syncCrosshairsWith !== this.props.syncCrosshairsWith
   226      ) {
   227        const newFlotOptions = this.state.flotOptions;
   228        newFlotOptions.grid.markings = this.plotMarkings();
   229        newFlotOptions.annotations = this.composeAnnotationsList();
   230        newFlotOptions.syncCrosshairsWith = this.props.syncCrosshairsWith;
   231  
   232        this.setState({ flotOptions: newFlotOptions });
   233      }
   234    }
   235  
   236    composeAnnotationsList = () => {
   237      return Array.isArray(this.props.annotations)
   238        ? this.props.annotations?.map((a) => ({
   239            timestamp: a.timestamp,
   240            content: a.content,
   241            type: 'message',
   242            color: ANNOTATION_COLOR,
   243          }))
   244        : [];
   245    };
   246  
   247    plotMarkings = () => {
   248      const selectionMarkings = markingsFromSelection(
   249        this.props.selectionType,
   250        this.props.selection?.left,
   251        this.props.selection?.right
   252      );
   253  
   254      return [...selectionMarkings];
   255    };
   256  
   257    setOnHoverDisplayTooltip = (
   258      data: ITooltipWrapperProps & TooltipCallbackProps
   259    ) => {
   260      const tooltipContent = [];
   261  
   262      const TooltipBody: React.FC<TooltipCallbackProps> | undefined =
   263        this.props?.onHoverDisplayTooltip;
   264  
   265      if (TooltipBody) {
   266        tooltipContent.push(
   267          <TooltipBody
   268            key="explore-body"
   269            values={data.values}
   270            timeLabel={data.timeLabel}
   271          />
   272        );
   273      }
   274  
   275      if (tooltipContent.length) {
   276        return (
   277          <TooltipWrapper
   278            align={data.align}
   279            pageY={data.pageY}
   280            pageX={data.pageX}
   281          >
   282            {tooltipContent.map((tooltipBody) => tooltipBody)}
   283          </TooltipWrapper>
   284        );
   285      }
   286  
   287      return null;
   288    };
   289  
   290    renderMultiple = (props: MultipleDataProps) => {
   291      const { flotOptions } = this.state;
   292      const { timelineGroups, activeGroup, showTagsLegend } = props;
   293      const { timezone } = this.props;
   294  
   295      // TODO: unify with renderSingle
   296      const onHoverDisplayTooltip = (
   297        data: ITooltipWrapperProps & TooltipCallbackProps
   298      ) => this.setOnHoverDisplayTooltip(data);
   299  
   300      const customFlotOptions = {
   301        ...flotOptions,
   302        onHoverDisplayTooltip,
   303        ContextMenu: this.props.ContextMenu,
   304        xaxis: { ...flotOptions.xaxis, autoscaleMargin: null, timezone },
   305        wrapperId: this.props.id,
   306      };
   307  
   308      const centeredTimelineGroups = timelineGroups.map(
   309        ({ data, color, tagName }) => {
   310          return {
   311            data: centerTimelineData({ data }),
   312            tagName,
   313            color:
   314              activeGroup && activeGroup !== tagName ? color?.fade(0.75) : color,
   315          };
   316        }
   317      );
   318  
   319      return (
   320        <>
   321          {this.timelineChart(centeredTimelineGroups, customFlotOptions)}
   322          {showTagsLegend && (
   323            <Legend
   324              activeGroup={activeGroup}
   325              groups={timelineGroups}
   326              handleGroupByTagValueChange={props.handleGroupByTagValueChange}
   327            />
   328          )}
   329        </>
   330      );
   331    };
   332  
   333    renderSingle = (props: SingleDataProps) => {
   334      const { flotOptions } = this.state;
   335      const { timelineA } = props;
   336      let { timelineB } = props;
   337      const { timezone, title } = this.props;
   338  
   339      // TODO deep copy
   340      timelineB = timelineB ? JSON.parse(JSON.stringify(timelineB)) : undefined;
   341  
   342      // TODO: unify with renderMultiple
   343      const onHoverDisplayTooltip = (
   344        data: ITooltipWrapperProps & TooltipCallbackProps
   345      ) => this.setOnHoverDisplayTooltip(data);
   346  
   347      const customFlotOptions = {
   348        ...flotOptions,
   349        onHoverDisplayTooltip,
   350        ContextMenu: this.props.ContextMenu,
   351        wrapperId: this.props.id,
   352        xaxis: {
   353          ...flotOptions.xaxis,
   354          // In case there are few chunks left, then we'd like to add some margins to
   355          // both sides making it look more centers
   356          autoscaleMargin:
   357            timelineA?.data && timelineA.data.samples.length > 3 ? null : 0.005,
   358          timezone,
   359        },
   360      };
   361  
   362      // Since this may be overwritten, we always need to set it up correctly
   363      if (timelineA && timelineB) {
   364        customFlotOptions.bars.show = false;
   365      } else {
   366        customFlotOptions.bars.show = true;
   367      }
   368  
   369      // If they are the same, skew the second one slightly so that they are both visible
   370      if (areTimelinesTheSame(timelineA, timelineB)) {
   371        // the factor is completely arbitrary, we use a positive number to skew above
   372        timelineB = skewTimeline(timelineB, 4);
   373      }
   374  
   375      if (isSingleDatapoint(timelineA, timelineB)) {
   376        // check if both have a single value
   377        // if so, let's use bars
   378        // since we can't put a point when there's no data when using points
   379        if (timelineB && timelineB.data && timelineB.data.samples.length <= 1) {
   380          customFlotOptions.bars.show = true;
   381  
   382          // Also slightly skew to show them side by side
   383          timelineB.data.startTime += 0.01;
   384        }
   385      }
   386  
   387      const data = [
   388        timelineA &&
   389          timelineA.data && {
   390            ...timelineA,
   391            data: centerTimelineData(timelineA),
   392          },
   393        timelineB &&
   394          timelineB.data && { ...timelineB, data: centerTimelineData(timelineB) },
   395      ].filter((a) => !!a);
   396  
   397      return (
   398        <>
   399          {title}
   400          {this.timelineChart(data, customFlotOptions)}
   401        </>
   402      );
   403    };
   404  
   405    timelineChart = (
   406      data: ({ data: number[][]; color?: string | Color } | undefined)[],
   407      customFlotOptions: ShamefulAny
   408    ) => {
   409      return (
   410        <TimelineChart
   411          onSelect={this.props.onSelect}
   412          className={styles.wrapper}
   413          data-testid={this.props['data-testid']}
   414          id={this.props.id}
   415          options={customFlotOptions}
   416          data={data}
   417          width="100%"
   418          height={this.props.height}
   419        />
   420      );
   421    };
   422  
   423    render = () => {
   424      if (this.props.mode === 'multiple') {
   425        return this.renderMultiple(this.props);
   426      }
   427      return this.renderSingle(this.props);
   428    };
   429  }
   430  
   431  function isSingleDatapoint(timelineA: TimelineData, timelineB?: TimelineData) {
   432    const aIsSingle = timelineA.data && timelineA.data.samples.length <= 1;
   433    if (!aIsSingle) {
   434      return false;
   435    }
   436  
   437    if (timelineB && timelineB.data) {
   438      return timelineB.data.samples.length <= 1;
   439    }
   440  
   441    return true;
   442  }
   443  
   444  function skewTimeline(
   445    timeline: TimelineData | undefined,
   446    factor: number
   447  ): TimelineData | undefined {
   448    if (!timeline) {
   449      return undefined;
   450    }
   451  
   452    // TODO: deep copy
   453    const copy = JSON.parse(JSON.stringify(timeline)) as typeof timeline;
   454  
   455    if (copy && copy.data) {
   456      let min = copy.data.samples[0];
   457      let max = copy.data.samples[0];
   458  
   459      for (let i = 0; i < copy.data.samples.length; i += 1) {
   460        const b = copy.data.samples[i];
   461  
   462        if (b < min) {
   463          min = b;
   464        }
   465        if (b > max) {
   466          max = b;
   467        }
   468      }
   469  
   470      const height = 100; // px
   471      const skew = (max - min) / height;
   472  
   473      if (copy.data) {
   474        copy.data.samples = copy.data.samples.map((a) => {
   475          // We don't want to skew negative values, since users are expecting an absent value
   476          if (a <= 0) {
   477            return 0;
   478          }
   479  
   480          // 4 is completely arbitrary, it was eyeballed
   481          return a + skew * factor;
   482        });
   483      }
   484    }
   485  
   486    return copy;
   487  }
   488  
   489  function areTimelinesTheSame(
   490    timelineA: TimelineData,
   491    timelineB?: TimelineData
   492  ) {
   493    if (!timelineA || !timelineB) {
   494      // for all purposes let's consider two empty timelines the same
   495      // since we want to transform them
   496      return false;
   497    }
   498    const dataA = timelineA.data;
   499    const dataB = timelineB.data;
   500  
   501    if (!dataA || !dataB) {
   502      return false;
   503    }
   504  
   505    // Find the biggest one
   506    const biggest = dataA.samples.length > dataB.samples.length ? dataA : dataB;
   507    const smallest = dataA.samples.length < dataB.samples.length ? dataA : dataB;
   508  
   509    const map = new Map(biggest.samples.map((a) => [a, true]));
   510  
   511    return smallest.samples.every((a) => map.has(a));
   512  }
   513  
   514  export default TimelineChartWrapper;