1import React, { PureComponent } from 'react';
2import { AlignedData, Range } from 'uplot';
3import {
4  compareDataFrameStructures,
5  DataFrame,
6  Field,
7  FieldConfig,
8  FieldSparkline,
9  FieldType,
10  getFieldColorModeForField,
11} from '@grafana/data';
12import {
13  AxisPlacement,
14  GraphDrawStyle,
15  GraphFieldConfig,
16  VisibilityMode,
17  ScaleDirection,
18  ScaleOrientation,
19} from '@grafana/schema';
20import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
21import { UPlotChart } from '../uPlot/Plot';
22import { Themeable2 } from '../../types';
23import { preparePlotData } from '../uPlot/utils';
24import { preparePlotFrame } from './utils';
25import { isEqual } from 'lodash';
26
27export interface SparklineProps extends Themeable2 {
28  width: number;
29  height: number;
30  config?: FieldConfig<GraphFieldConfig>;
31  sparkline: FieldSparkline;
32}
33
34interface State {
35  data: AlignedData;
36  alignedDataFrame: DataFrame;
37  configBuilder: UPlotConfigBuilder;
38}
39
40const defaultConfig: GraphFieldConfig = {
41  drawStyle: GraphDrawStyle.Line,
42  showPoints: VisibilityMode.Auto,
43  axisPlacement: AxisPlacement.Hidden,
44};
45
46export class Sparkline extends PureComponent<SparklineProps, State> {
47  constructor(props: SparklineProps) {
48    super(props);
49
50    const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
51
52    this.state = {
53      data: preparePlotData([alignedDataFrame]),
54      alignedDataFrame,
55      configBuilder: this.prepareConfig(alignedDataFrame),
56    };
57  }
58
59  static getDerivedStateFromProps(props: SparklineProps, state: State) {
60    const frame = preparePlotFrame(props.sparkline, props.config);
61    if (!frame) {
62      return { ...state };
63    }
64
65    return {
66      ...state,
67      data: preparePlotData([frame]),
68      alignedDataFrame: frame,
69    };
70  }
71
72  componentDidUpdate(prevProps: SparklineProps, prevState: State) {
73    const { alignedDataFrame } = this.state;
74
75    if (!alignedDataFrame) {
76      return;
77    }
78
79    let rebuildConfig = false;
80
81    if (prevProps.sparkline !== this.props.sparkline) {
82      rebuildConfig = !compareDataFrameStructures(this.state.alignedDataFrame, prevState.alignedDataFrame);
83    } else {
84      rebuildConfig = !isEqual(prevProps.config, this.props.config);
85    }
86
87    if (rebuildConfig) {
88      this.setState({ configBuilder: this.prepareConfig(alignedDataFrame) });
89    }
90  }
91
92  getYRange(field: Field) {
93    let { min, max } = this.state.alignedDataFrame.fields[1].state?.range!;
94
95    if (min === max) {
96      if (min === 0) {
97        max = 100;
98      } else {
99        min = 0;
100        max! *= 2;
101      }
102    }
103
104    return [
105      Math.max(min!, field.config.min ?? -Infinity),
106      Math.min(max!, field.config.max ?? Infinity),
107    ] as Range.MinMax;
108  }
109
110  prepareConfig(data: DataFrame) {
111    const { theme } = this.props;
112    const builder = new UPlotConfigBuilder();
113
114    builder.setCursor({
115      show: false,
116      x: false, // no crosshairs
117      y: false,
118    });
119
120    // X is the first field in the alligned frame
121    const xField = data.fields[0];
122    builder.addScale({
123      scaleKey: 'x',
124      orientation: ScaleOrientation.Horizontal,
125      direction: ScaleDirection.Right,
126      isTime: false, //xField.type === FieldType.time,
127      range: () => {
128        const { sparkline } = this.props;
129        if (sparkline.x) {
130          if (sparkline.timeRange && sparkline.x.type === FieldType.time) {
131            return [sparkline.timeRange.from.valueOf(), sparkline.timeRange.to.valueOf()];
132          }
133          const vals = sparkline.x.values;
134          return [vals.get(0), vals.get(vals.length - 1)];
135        }
136        return [0, sparkline.y.values.length - 1];
137      },
138    });
139
140    builder.addAxis({
141      scaleKey: 'x',
142      theme,
143      placement: AxisPlacement.Hidden,
144    });
145
146    for (let i = 0; i < data.fields.length; i++) {
147      const field = data.fields[i];
148      const config = field.config as FieldConfig<GraphFieldConfig>;
149      const customConfig: GraphFieldConfig = {
150        ...defaultConfig,
151        ...config.custom,
152      };
153
154      if (field === xField || field.type !== FieldType.number) {
155        continue;
156      }
157
158      const scaleKey = config.unit || '__fixed';
159      builder.addScale({
160        scaleKey,
161        orientation: ScaleOrientation.Vertical,
162        direction: ScaleDirection.Up,
163        range: () => this.getYRange(field),
164      });
165
166      builder.addAxis({
167        scaleKey,
168        theme,
169        placement: AxisPlacement.Hidden,
170      });
171
172      const colorMode = getFieldColorModeForField(field);
173      const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
174      const pointsMode =
175        customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints;
176
177      builder.addSeries({
178        pxAlign: false,
179        scaleKey,
180        theme,
181        drawStyle: customConfig.drawStyle!,
182        lineColor: customConfig.lineColor ?? seriesColor,
183        lineWidth: customConfig.lineWidth,
184        lineInterpolation: customConfig.lineInterpolation,
185        showPoints: pointsMode,
186        pointSize: customConfig.pointSize,
187        fillOpacity: customConfig.fillOpacity,
188        fillColor: customConfig.fillColor ?? seriesColor,
189      });
190    }
191
192    return builder;
193  }
194
195  render() {
196    const { data, configBuilder } = this.state;
197    const { width, height, sparkline } = this.props;
198    return (
199      <UPlotChart data={data} config={configBuilder} width={width} height={height} timeRange={sparkline.timeRange!} />
200    );
201  }
202}
203