1import React from 'react';
2import uPlot, { AlignedData } from 'uplot';
3import { Themeable2 } from '../../types';
4import { findMidPointYPosition, pluginLog } from '../uPlot/utils';
5import {
6  DataFrame,
7  DataHoverClearEvent,
8  DataHoverEvent,
9  Field,
10  FieldMatcherID,
11  fieldMatchers,
12  LegacyGraphHoverEvent,
13  TimeRange,
14  TimeZone,
15} from '@grafana/data';
16import { preparePlotFrame as defaultPreparePlotFrame } from './utils';
17import { VizLegendOptions } from '@grafana/schema';
18import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext';
19import { Subscription } from 'rxjs';
20import { throttleTime } from 'rxjs/operators';
21import { GraphNGLegendEvent, XYFieldMatchers } from './types';
22import { Renderers, UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
23import { VizLayout } from '../VizLayout/VizLayout';
24import { UPlotChart } from '../uPlot/Plot';
25import { ScaleProps } from '../uPlot/config/UPlotScaleBuilder';
26import { AxisProps } from '../uPlot/config/UPlotAxisBuilder';
27
28/**
29 * @internal -- not a public API
30 */
31export const FIXED_UNIT = '__fixed';
32
33/**
34 * @internal -- not a public API
35 */
36export type PropDiffFn<T extends any = any> = (prev: T, next: T) => boolean;
37
38export interface GraphNGProps extends Themeable2 {
39  frames: DataFrame[];
40  structureRev?: number; // a number that will change when the frames[] structure changes
41  width: number;
42  height: number;
43  timeRange: TimeRange;
44  timeZone: TimeZone;
45  legend: VizLegendOptions;
46  fields?: XYFieldMatchers; // default will assume timeseries data
47  renderers?: Renderers;
48  tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps;
49  tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps;
50  onLegendClick?: (event: GraphNGLegendEvent) => void;
51  children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode;
52  prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder;
53  propsToDiff?: Array<string | PropDiffFn>;
54  preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame;
55  renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null;
56
57  /**
58   * needed for propsToDiff to re-init the plot & config
59   * this is a generic approach to plot re-init, without having to specify which panel-level options
60   * should cause invalidation. we can drop this in favor of something like panelOptionsRev that gets passed in
61   * similar to structureRev. then we can drop propsToDiff entirely.
62   */
63  options?: Record<string, any>;
64}
65
66function sameProps(prevProps: any, nextProps: any, propsToDiff: Array<string | PropDiffFn> = []) {
67  for (const propName of propsToDiff) {
68    if (typeof propName === 'function') {
69      if (!propName(prevProps, nextProps)) {
70        return false;
71      }
72    } else if (nextProps[propName] !== prevProps[propName]) {
73      return false;
74    }
75  }
76
77  return true;
78}
79
80/**
81 * @internal -- not a public API
82 */
83export interface GraphNGState {
84  alignedFrame: DataFrame;
85  alignedData: AlignedData;
86  config?: UPlotConfigBuilder;
87}
88
89/**
90 * "Time as X" core component, expects ascending x
91 */
92export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
93  static contextType = PanelContextRoot;
94  panelContext: PanelContext = {} as PanelContext;
95  private plotInstance: React.RefObject<uPlot>;
96
97  private subscription = new Subscription();
98
99  constructor(props: GraphNGProps) {
100    super(props);
101    this.state = this.prepState(props);
102    this.plotInstance = React.createRef();
103  }
104
105  getTimeRange = () => this.props.timeRange;
106
107  prepState(props: GraphNGProps, withConfig = true) {
108    let state: GraphNGState = null as any;
109
110    const { frames, fields, preparePlotFrame } = props;
111
112    const preparePlotFrameFn = preparePlotFrame || defaultPreparePlotFrame;
113
114    const alignedFrame = preparePlotFrameFn(
115      frames,
116      fields || {
117        x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
118        y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
119      }
120    );
121    pluginLog('GraphNG', false, 'data aligned', alignedFrame);
122
123    if (alignedFrame) {
124      let config = this.state?.config;
125
126      if (withConfig) {
127        config = props.prepConfig(alignedFrame, this.props.frames, this.getTimeRange);
128        pluginLog('GraphNG', false, 'config prepared', config);
129      }
130
131      state = {
132        alignedFrame,
133        alignedData: config!.prepData!([alignedFrame]) as AlignedData,
134        config,
135      };
136
137      pluginLog('GraphNG', false, 'data prepared', state.alignedData);
138    }
139
140    return state;
141  }
142
143  handleCursorUpdate(evt: DataHoverEvent | LegacyGraphHoverEvent) {
144    const time = evt.payload?.point?.time;
145    const u = this.plotInstance.current;
146    if (u && time) {
147      // Try finding left position on time axis
148      const left = u.valToPos(time, 'x');
149      let top;
150      if (left) {
151        // find midpoint between points at current idx
152        top = findMidPointYPosition(u, u.posToIdx(left));
153      }
154
155      if (!top || !left) {
156        return;
157      }
158
159      u.setCursor({
160        left,
161        top,
162      });
163    }
164  }
165
166  componentDidMount() {
167    this.panelContext = this.context as PanelContext;
168    const { eventBus } = this.panelContext;
169
170    this.subscription.add(
171      eventBus
172        .getStream(DataHoverEvent)
173        .pipe(throttleTime(50))
174        .subscribe({
175          next: (evt) => {
176            if (eventBus === evt.origin) {
177              return;
178            }
179            this.handleCursorUpdate(evt);
180          },
181        })
182    );
183
184    // Legacy events (from flot graph)
185    this.subscription.add(
186      eventBus
187        .getStream(LegacyGraphHoverEvent)
188        .pipe(throttleTime(50))
189        .subscribe({
190          next: (evt) => this.handleCursorUpdate(evt),
191        })
192    );
193
194    this.subscription.add(
195      eventBus
196        .getStream(DataHoverClearEvent)
197        .pipe(throttleTime(50))
198        .subscribe({
199          next: () => {
200            const u = this.plotInstance?.current;
201
202            if (u) {
203              u.setCursor({
204                left: -10,
205                top: -10,
206              });
207            }
208          },
209        })
210    );
211  }
212
213  componentDidUpdate(prevProps: GraphNGProps) {
214    const { frames, structureRev, timeZone, propsToDiff } = this.props;
215
216    const propsChanged = !sameProps(prevProps, this.props, propsToDiff);
217
218    if (frames !== prevProps.frames || propsChanged) {
219      let newState = this.prepState(this.props, false);
220
221      if (newState) {
222        const shouldReconfig =
223          this.state.config === undefined ||
224          timeZone !== prevProps.timeZone ||
225          structureRev !== prevProps.structureRev ||
226          !structureRev ||
227          propsChanged;
228
229        if (shouldReconfig) {
230          newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange);
231          newState.alignedData = newState.config.prepData!([newState.alignedFrame]) as AlignedData;
232          pluginLog('GraphNG', false, 'config recreated', newState.config);
233        }
234      }
235
236      newState && this.setState(newState);
237    }
238  }
239
240  componentWillUnmount() {
241    this.subscription.unsubscribe();
242  }
243
244  render() {
245    const { width, height, children, timeRange, renderLegend } = this.props;
246    const { config, alignedFrame, alignedData } = this.state;
247
248    if (!config) {
249      return null;
250    }
251
252    return (
253      <VizLayout width={width} height={height} legend={renderLegend(config)}>
254        {(vizWidth: number, vizHeight: number) => (
255          <UPlotChart
256            config={config}
257            data={alignedData}
258            width={vizWidth}
259            height={vizHeight}
260            timeRange={timeRange}
261            plotRef={(u) => ((this.plotInstance as React.MutableRefObject<uPlot>).current = u)}
262          >
263            {children ? children(config, alignedFrame) : null}
264          </UPlotChart>
265        )}
266      </VizLayout>
267    );
268  }
269}
270