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