1import { isNumber } from 'lodash';
2import {
3  DashboardCursorSync,
4  DataFrame,
5  DataHoverClearEvent,
6  DataHoverEvent,
7  DataHoverPayload,
8  FieldConfig,
9  FieldType,
10  formattedValueToString,
11  getFieldColorModeForField,
12  getFieldSeriesColor,
13  getFieldDisplayName,
14} from '@grafana/data';
15
16import { UPlotConfigBuilder, UPlotConfigPrepFn } from '../uPlot/config/UPlotConfigBuilder';
17import {
18  AxisPlacement,
19  GraphDrawStyle,
20  GraphFieldConfig,
21  GraphTresholdsStyleMode,
22  VisibilityMode,
23  ScaleDirection,
24  ScaleOrientation,
25  VizLegendOptions,
26} from '@grafana/schema';
27import { collectStackingGroups, orderIdsByCalcs, preparePlotData } from '../uPlot/utils';
28import uPlot from 'uplot';
29import { buildScaleKey } from '../GraphNG/utils';
30
31const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
32
33const defaultConfig: GraphFieldConfig = {
34  drawStyle: GraphDrawStyle.Line,
35  showPoints: VisibilityMode.Auto,
36  axisPlacement: AxisPlacement.Auto,
37};
38
39export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
40  sync?: () => DashboardCursorSync;
41  legend?: VizLegendOptions;
42}> = ({
43  frame,
44  theme,
45  timeZone,
46  getTimeRange,
47  eventBus,
48  sync,
49  allFrames,
50  renderers,
51  legend,
52  tweakScale = (opts) => opts,
53  tweakAxis = (opts) => opts,
54}) => {
55  const builder = new UPlotConfigBuilder(timeZone);
56
57  builder.setPrepData((prepData) => preparePlotData(prepData, undefined, legend));
58
59  // X is the first field in the aligned frame
60  const xField = frame.fields[0];
61  if (!xField) {
62    return builder; // empty frame with no options
63  }
64
65  let seriesIndex = 0;
66
67  const xScaleKey = 'x';
68  let xScaleUnit = '_x';
69  let yScaleKey = '';
70
71  if (xField.type === FieldType.time) {
72    xScaleUnit = 'time';
73    builder.addScale({
74      scaleKey: xScaleKey,
75      orientation: ScaleOrientation.Horizontal,
76      direction: ScaleDirection.Right,
77      isTime: true,
78      range: () => {
79        const r = getTimeRange();
80        return [r.from.valueOf(), r.to.valueOf()];
81      },
82    });
83
84    builder.addAxis({
85      scaleKey: xScaleKey,
86      isTime: true,
87      placement: AxisPlacement.Bottom,
88      label: xField.config.custom?.axisLabel,
89      timeZone,
90      theme,
91      grid: { show: xField.config.custom?.axisGridShow },
92    });
93  } else {
94    // Not time!
95    if (xField.config.unit) {
96      xScaleUnit = xField.config.unit;
97    }
98
99    builder.addScale({
100      scaleKey: xScaleKey,
101      orientation: ScaleOrientation.Horizontal,
102      direction: ScaleDirection.Right,
103    });
104
105    builder.addAxis({
106      scaleKey: xScaleKey,
107      placement: AxisPlacement.Bottom,
108      label: xField.config.custom?.axisLabel,
109      theme,
110      grid: { show: xField.config.custom?.axisGridShow },
111    });
112  }
113
114  let customRenderedFields =
115    renderers?.flatMap((r) => Object.values(r.fieldMap).filter((name) => r.indicesOnly.indexOf(name) === -1)) ?? [];
116
117  const stackingGroups: Map<string, number[]> = new Map();
118
119  let indexByName: Map<string, number> | undefined;
120
121  for (let i = 1; i < frame.fields.length; i++) {
122    const field = frame.fields[i];
123
124    const config = {
125      ...field.config,
126      custom: {
127        ...defaultConfig,
128        ...field.config.custom,
129      },
130    } as FieldConfig<GraphFieldConfig>;
131
132    const customConfig: GraphFieldConfig = config.custom!;
133
134    if (field === xField || field.type !== FieldType.number) {
135      continue;
136    }
137
138    // TODO: skip this for fields with custom renderers?
139    field.state!.seriesIndex = seriesIndex++;
140
141    const fmt = field.display ?? defaultFormatter;
142
143    const scaleKey = buildScaleKey(config);
144    const colorMode = getFieldColorModeForField(field);
145    const scaleColor = getFieldSeriesColor(field, theme);
146    const seriesColor = scaleColor.color;
147
148    // The builder will manage unique scaleKeys and combine where appropriate
149    builder.addScale(
150      tweakScale(
151        {
152          scaleKey,
153          orientation: ScaleOrientation.Vertical,
154          direction: ScaleDirection.Up,
155          distribution: customConfig.scaleDistribution?.type,
156          log: customConfig.scaleDistribution?.log,
157          min: field.config.min,
158          max: field.config.max,
159          softMin: customConfig.axisSoftMin,
160          softMax: customConfig.axisSoftMax,
161        },
162        field
163      )
164    );
165
166    if (!yScaleKey) {
167      yScaleKey = scaleKey;
168    }
169
170    if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
171      builder.addAxis(
172        tweakAxis(
173          {
174            scaleKey,
175            label: customConfig.axisLabel,
176            size: customConfig.axisWidth,
177            placement: customConfig.axisPlacement ?? AxisPlacement.Auto,
178            formatValue: (v) => formattedValueToString(fmt(v)),
179            theme,
180            grid: { show: customConfig.axisGridShow },
181          },
182          field
183        )
184      );
185    }
186
187    const showPoints =
188      customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints;
189
190    let pointsFilter: uPlot.Series.Points.Filter = () => null;
191
192    if (customConfig.spanNulls !== true) {
193      pointsFilter = (u, seriesIdx, show, gaps) => {
194        let filtered = [];
195
196        let series = u.series[seriesIdx];
197
198        if (!show && gaps && gaps.length) {
199          const [firstIdx, lastIdx] = series.idxs!;
200          const xData = u.data[0];
201          const firstPos = Math.round(u.valToPos(xData[firstIdx], 'x', true));
202          const lastPos = Math.round(u.valToPos(xData[lastIdx], 'x', true));
203
204          if (gaps[0][0] === firstPos) {
205            filtered.push(firstIdx);
206          }
207
208          // show single points between consecutive gaps that share end/start
209          for (let i = 0; i < gaps.length; i++) {
210            let thisGap = gaps[i];
211            let nextGap = gaps[i + 1];
212
213            if (nextGap && thisGap[1] === nextGap[0]) {
214              filtered.push(u.posToIdx(thisGap[1], true));
215            }
216          }
217
218          if (gaps[gaps.length - 1][1] === lastPos) {
219            filtered.push(lastIdx);
220          }
221        }
222
223        return filtered.length ? filtered : null;
224      };
225    }
226
227    let { fillOpacity } = customConfig;
228
229    let pathBuilder: uPlot.Series.PathBuilder | null = null;
230    let pointsBuilder: uPlot.Series.Points.Show | null = null;
231
232    if (field.state?.origin) {
233      if (!indexByName) {
234        indexByName = getNamesToFieldIndex(frame, allFrames);
235      }
236
237      const originFrame = allFrames[field.state.origin.frameIndex];
238      const originField = originFrame?.fields[field.state.origin.fieldIndex];
239
240      const dispName = getFieldDisplayName(originField ?? field, originFrame, allFrames);
241
242      // disable default renderers
243      if (customRenderedFields.indexOf(dispName) >= 0) {
244        pathBuilder = () => null;
245        pointsBuilder = () => undefined;
246      }
247
248      if (customConfig.fillBelowTo) {
249        const t = indexByName.get(dispName);
250        const b = indexByName.get(customConfig.fillBelowTo);
251        if (isNumber(b) && isNumber(t)) {
252          builder.addBand({
253            series: [t, b],
254            fill: undefined, // using null will have the band use fill options from `t`
255          });
256
257          if (!fillOpacity) {
258            fillOpacity = 35; // default from flot
259          }
260        } else {
261          fillOpacity = 0;
262        }
263      }
264    }
265
266    builder.addSeries({
267      pathBuilder,
268      pointsBuilder,
269      scaleKey,
270      showPoints,
271      pointsFilter,
272      colorMode,
273      fillOpacity,
274      theme,
275      drawStyle: customConfig.drawStyle!,
276      lineColor: customConfig.lineColor ?? seriesColor,
277      lineWidth: customConfig.lineWidth,
278      lineInterpolation: customConfig.lineInterpolation,
279      lineStyle: customConfig.lineStyle,
280      barAlignment: customConfig.barAlignment,
281      barWidthFactor: customConfig.barWidthFactor,
282      barMaxWidth: customConfig.barMaxWidth,
283      pointSize: customConfig.pointSize,
284      spanNulls: customConfig.spanNulls || false,
285      show: !customConfig.hideFrom?.viz,
286      gradientMode: customConfig.gradientMode,
287      thresholds: config.thresholds,
288      hardMin: field.config.min,
289      hardMax: field.config.max,
290      softMin: customConfig.axisSoftMin,
291      softMax: customConfig.axisSoftMax,
292      // The following properties are not used in the uPlot config, but are utilized as transport for legend config
293      dataFrameFieldIndex: field.state?.origin,
294    });
295
296    // Render thresholds in graph
297    if (customConfig.thresholdsStyle && config.thresholds) {
298      const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off;
299      if (thresholdDisplay !== GraphTresholdsStyleMode.Off) {
300        builder.addThresholds({
301          config: customConfig.thresholdsStyle,
302          thresholds: config.thresholds,
303          scaleKey,
304          theme,
305          hardMin: field.config.min,
306          hardMax: field.config.max,
307          softMin: customConfig.axisSoftMin,
308          softMax: customConfig.axisSoftMax,
309        });
310      }
311    }
312    collectStackingGroups(field, stackingGroups, seriesIndex);
313  }
314
315  if (stackingGroups.size !== 0) {
316    for (const [_, seriesIds] of stackingGroups.entries()) {
317      const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame });
318      for (let j = seriesIdxs.length - 1; j > 0; j--) {
319        builder.addBand({
320          series: [seriesIdxs[j], seriesIdxs[j - 1]],
321        });
322      }
323    }
324  }
325
326  // hook up custom/composite renderers
327  renderers?.forEach((r) => {
328    if (!indexByName) {
329      indexByName = getNamesToFieldIndex(frame, allFrames);
330    }
331    let fieldIndices: Record<string, number> = {};
332
333    for (let key in r.fieldMap) {
334      let dispName = r.fieldMap[key];
335      fieldIndices[key] = indexByName.get(dispName)!;
336    }
337
338    r.init(builder, fieldIndices);
339  });
340
341  builder.scaleKeys = [xScaleKey, yScaleKey];
342
343  // if hovered value is null, how far we may scan left/right to hover nearest non-null
344  const hoverProximityPx = 15;
345
346  let cursor: Partial<uPlot.Cursor> = {
347    // this scans left and right from cursor position to find nearest data index with value != null
348    // TODO: do we want to only scan past undefined values, but halt at explicit null values?
349    dataIdx: (self, seriesIdx, hoveredIdx, cursorXVal) => {
350      let seriesData = self.data[seriesIdx];
351
352      if (seriesData[hoveredIdx] == null) {
353        let nonNullLft = hoveredIdx,
354          nonNullRgt = hoveredIdx,
355          i;
356
357        i = hoveredIdx;
358        while (nonNullLft === hoveredIdx && i-- > 0) {
359          if (seriesData[i] != null) {
360            nonNullLft = i;
361          }
362        }
363
364        i = hoveredIdx;
365        while (nonNullRgt === hoveredIdx && i++ < seriesData.length) {
366          if (seriesData[i] != null) {
367            nonNullRgt = i;
368          }
369        }
370
371        let xVals = self.data[0];
372
373        let curPos = self.valToPos(cursorXVal, 'x');
374        let rgtPos = self.valToPos(xVals[nonNullRgt], 'x');
375        let lftPos = self.valToPos(xVals[nonNullLft], 'x');
376
377        let lftDelta = curPos - lftPos;
378        let rgtDelta = rgtPos - curPos;
379
380        if (lftDelta <= rgtDelta) {
381          if (lftDelta <= hoverProximityPx) {
382            hoveredIdx = nonNullLft;
383          }
384        } else {
385          if (rgtDelta <= hoverProximityPx) {
386            hoveredIdx = nonNullRgt;
387          }
388        }
389      }
390
391      return hoveredIdx;
392    },
393  };
394
395  if (sync && sync() !== DashboardCursorSync.Off) {
396    const payload: DataHoverPayload = {
397      point: {
398        [xScaleKey]: null,
399        [yScaleKey]: null,
400      },
401      data: frame,
402    };
403    const hoverEvent = new DataHoverEvent(payload);
404    cursor.sync = {
405      key: '__global_',
406      filters: {
407        pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
408          if (sync && sync() === DashboardCursorSync.Off) {
409            return false;
410          }
411
412          payload.rowIndex = dataIdx;
413          if (x < 0 && y < 0) {
414            payload.point[xScaleUnit] = null;
415            payload.point[yScaleKey] = null;
416            eventBus.publish(new DataHoverClearEvent());
417          } else {
418            // convert the points
419            payload.point[xScaleUnit] = src.posToVal(x, xScaleKey);
420            payload.point[yScaleKey] = src.posToVal(y, yScaleKey);
421            payload.point.panelRelY = y > 0 ? y / h : 1; // used by old graph panel to position tooltip
422            eventBus.publish(hoverEvent);
423            hoverEvent.payload.down = undefined;
424          }
425          return true;
426        },
427      },
428      // ??? setSeries: syncMode === DashboardCursorSync.Tooltip,
429      //TODO: remove any once https://github.com/leeoniya/uPlot/pull/611 got merged or the typing is fixed
430      scales: [xScaleKey, null as any],
431      match: [() => true, () => true],
432    };
433  }
434
435  builder.setSync();
436  builder.setCursor(cursor);
437
438  return builder;
439};
440
441export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
442  const originNames = new Map<string, number>();
443  frame.fields.forEach((field, i) => {
444    const origin = field.state?.origin;
445    if (origin) {
446      const origField = allFrames[origin.frameIndex]?.fields[origin.fieldIndex];
447      if (origField) {
448        originNames.set(getFieldDisplayName(origField, allFrames[origin.frameIndex], allFrames), i);
449      }
450    }
451  });
452  return originNames;
453}
454