import React from 'react'; import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types'; import { ArrayVector, DataFrame, FALLBACK_COLOR, Field, FieldColorModeId, FieldConfig, FieldType, formattedValueToString, getFieldDisplayName, getValueFormat, GrafanaTheme2, getActiveThreshold, Threshold, getFieldConfigWithMinMax, outerJoinDataFrames, ThresholdsMode, } from '@grafana/data'; import { FIXED_UNIT, SeriesVisibilityChangeMode, UPlotConfigBuilder, UPlotConfigPrepFn, VizLegendItem, } from '@grafana/ui'; import { getConfig, TimelineCoreOptions } from './timeline'; import { VizLegendOptions, AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema'; import { TimelineFieldConfig, TimelineOptions } from './types'; import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types'; import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils'; const defaultConfig: TimelineFieldConfig = { lineWidth: 0, fillOpacity: 80, }; export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode { if (event.ctrlKey || event.metaKey || event.shiftKey) { return SeriesVisibilityChangeMode.AppendToSelection; } return SeriesVisibilityChangeMode.ToggleSelection; } export function preparePlotFrame(data: DataFrame[], dimFields: XYFieldMatchers) { return outerJoinDataFrames({ frames: data, joinBy: dimFields.x, keep: dimFields.y, keepOriginIndices: true, }); } export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ frame, theme, timeZone, getTimeRange, mode, rowHeight, colWidth, showValue, alignValue, }) => { const builder = new UPlotConfigBuilder(timeZone); const isDiscrete = (field: Field) => { const mode = field.config?.color?.mode; return !(mode && field.display && mode.startsWith('continuous-')); }; const getValueColor = (seriesIdx: number, value: any) => { const field = frame.fields[seriesIdx]; if (field.display) { const disp = field.display(value); // will apply color modes if (disp.color) { return disp.color; } } return FALLBACK_COLOR; }; const opts: TimelineCoreOptions = { // should expose in panel config mode: mode!, numSeries: frame.fields.length - 1, isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]), rowHeight: rowHeight!, colWidth: colWidth, showValue: showValue!, alignValue, theme, label: (seriesIdx) => getFieldDisplayName(frame.fields[seriesIdx], frame), getFieldConfig: (seriesIdx) => frame.fields[seriesIdx].config.custom, getValueColor, getTimeRange, // hardcoded formatter for state values formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)), onHover: (seriesIndex, valueIndex) => { hoveredSeriesIdx = seriesIndex; hoveredDataIdx = valueIndex; shouldChangeHover = true; }, onLeave: () => { hoveredSeriesIdx = null; hoveredDataIdx = null; shouldChangeHover = true; }, }; let shouldChangeHover = false; let hoveredSeriesIdx: number | null = null; let hoveredDataIdx: number | null = null; const coreConfig = getConfig(opts); builder.addHook('init', coreConfig.init); builder.addHook('drawClear', coreConfig.drawClear); builder.addHook('setCursor', coreConfig.setCursor); // in TooltipPlugin, this gets invoked and the result is bound to a setCursor hook // which fires after the above setCursor hook, so can take advantage of hoveringOver // already set by the above onHover/onLeave callbacks that fire from coreConfig.setCursor const interpolateTooltip: PlotTooltipInterpolator = ( updateActiveSeriesIdx, updateActiveDatapointIdx, updateTooltipPosition ) => { if (shouldChangeHover) { if (hoveredSeriesIdx != null) { updateActiveSeriesIdx(hoveredSeriesIdx); updateActiveDatapointIdx(hoveredDataIdx); } shouldChangeHover = false; } updateTooltipPosition(hoveredSeriesIdx == null); }; builder.setTooltipInterpolator(interpolateTooltip); builder.setPrepData(preparePlotData); builder.setCursor(coreConfig.cursor); builder.addScale({ scaleKey: 'x', isTime: true, orientation: ScaleOrientation.Horizontal, direction: ScaleDirection.Right, range: coreConfig.xRange, }); builder.addScale({ scaleKey: FIXED_UNIT, // y isTime: false, orientation: ScaleOrientation.Vertical, direction: ScaleDirection.Up, range: coreConfig.yRange, }); builder.addAxis({ scaleKey: 'x', isTime: true, splits: coreConfig.xSplits!, placement: AxisPlacement.Bottom, timeZone, theme, grid: { show: true }, }); builder.addAxis({ scaleKey: FIXED_UNIT, // y isTime: false, placement: AxisPlacement.Left, splits: coreConfig.ySplits, values: coreConfig.yValues, grid: { show: false }, ticks: false, gap: 16, theme, }); let seriesIndex = 0; for (let i = 0; i < frame.fields.length; i++) { if (i === 0) { continue; } const field = frame.fields[i]; const config = field.config as FieldConfig; const customConfig: TimelineFieldConfig = { ...defaultConfig, ...config.custom, }; field.state!.seriesIndex = seriesIndex++; // const scaleKey = config.unit || FIXED_UNIT; // const colorMode = getFieldColorModeForField(field); builder.addSeries({ scaleKey: FIXED_UNIT, pathBuilder: coreConfig.drawPaths, pointsBuilder: coreConfig.drawPoints, //colorMode, lineWidth: customConfig.lineWidth, fillOpacity: customConfig.fillOpacity, theme, show: !customConfig.hideFrom?.viz, thresholds: config.thresholds, // The following properties are not used in the uPlot config, but are utilized as transport for legend config dataFrameFieldIndex: field.state?.origin, }); } return builder; }; export function getNamesToFieldIndex(frame: DataFrame): Map { const names = new Map(); for (let i = 0; i < frame.fields.length; i++) { names.set(getFieldDisplayName(frame.fields[i], frame), i); } return names; } /** * If any sequential duplicate values exist, this will return a new array * with the future values set to undefined. * * in: 1, 1,undefined, 1,2, 2,null,2,3 * out: 1,undefined,undefined,undefined,2,undefined,null,2,3 */ export function unsetSameFutureValues(values: any[]): any[] | undefined { let prevVal = values[0]; let clone: any[] | undefined = undefined; for (let i = 1; i < values.length; i++) { let value = values[i]; if (value === null) { prevVal = null; } else { if (value === prevVal) { if (!clone) { clone = [...values]; } clone[i] = undefined; } else if (value != null) { prevVal = value; } } } return clone; } /** * Merge values by the threshold */ export function mergeThresholdValues(field: Field, theme: GrafanaTheme2): Field | undefined { const thresholds = field.config.thresholds; if (field.type !== FieldType.number || !thresholds || !thresholds.steps.length) { return undefined; } const items = getThresholdItems(field.config, theme); if (items.length !== thresholds.steps.length) { return undefined; // should not happen } const thresholdToText = new Map(); const textToColor = new Map(); for (let i = 0; i < items.length; i++) { thresholdToText.set(thresholds.steps[i], items[i].label); textToColor.set(items[i].label, items[i].color!); } let prev: Threshold | undefined = undefined; let input = field.values.toArray(); const vals = new Array(field.values.length); if (thresholds.mode === ThresholdsMode.Percentage) { const { min, max } = getFieldConfigWithMinMax(field); const delta = max! - min!; input = input.map((v) => { if (v == null) { return v; } return ((v - min!) / delta) * 100; }); } for (let i = 0; i < vals.length; i++) { const v = input[i]; if (v == null) { vals[i] = v; prev = undefined; } const active = getActiveThreshold(v, thresholds.steps); if (active === prev) { vals[i] = undefined; } else { vals[i] = thresholdToText.get(active); } prev = active; } return { ...field, type: FieldType.string, values: new ArrayVector(vals), display: (value: string) => ({ text: value, color: textToColor.get(value), numeric: NaN, }), }; } // This will return a set of frames with only graphable values included export function prepareTimelineFields( series: DataFrame[] | undefined, mergeValues: boolean, theme: GrafanaTheme2 ): { frames?: DataFrame[]; warn?: string } { if (!series?.length) { return { warn: 'No data in response' }; } let hasTimeseries = false; const frames: DataFrame[] = []; for (let frame of series) { let isTimeseries = false; let changed = false; const fields: Field[] = []; for (let field of frame.fields) { switch (field.type) { case FieldType.time: isTimeseries = true; hasTimeseries = true; fields.push(field); break; case FieldType.number: if (mergeValues && field.config.color?.mode === FieldColorModeId.Thresholds) { const f = mergeThresholdValues(field, theme); if (f) { fields.push(f); changed = true; continue; } } case FieldType.boolean: case FieldType.string: field = { ...field, config: { ...field.config, custom: { ...field.config.custom, // magic value for join() to leave nulls alone spanNulls: -1, }, }, }; if (mergeValues) { let merged = unsetSameFutureValues(field.values.toArray()); if (merged) { fields.push({ ...field, values: new ArrayVector(merged), }); changed = true; continue; } } fields.push(field); break; default: changed = true; } } if (isTimeseries && fields.length > 1) { hasTimeseries = true; if (changed) { frames.push({ ...frame, fields, }); } else { frames.push(frame); } } } if (!hasTimeseries) { return { warn: 'Data does not have a time field' }; } if (!frames.length) { return { warn: 'No graphable fields' }; } return { frames }; } export function getThresholdItems(fieldConfig: FieldConfig, theme: GrafanaTheme2): VizLegendItem[] { const items: VizLegendItem[] = []; const thresholds = fieldConfig.thresholds; if (!thresholds || !thresholds.steps.length) { return items; } const steps = thresholds.steps; const disp = getValueFormat(thresholds.mode === ThresholdsMode.Percentage ? 'percent' : fieldConfig.unit ?? ''); const fmt = (v: number) => formattedValueToString(disp(v)); for (let i = 1; i <= steps.length; i++) { const step = steps[i - 1]; items.push({ label: i === 1 ? `< ${fmt(steps[i].value)}` : `${fmt(step.value)}+`, color: theme.visualization.getColorByName(step.color), yAxis: 1, }); } return items; } export function prepareTimelineLegendItems( frames: DataFrame[] | undefined, options: VizLegendOptions, theme: GrafanaTheme2 ): VizLegendItem[] | undefined { if (!frames || options.displayMode === 'hidden') { return undefined; } const fields = allNonTimeFields(frames); if (!fields.length) { return undefined; } const items: VizLegendItem[] = []; const fieldConfig = fields[0].config; const colorMode = fieldConfig.color?.mode ?? FieldColorModeId.Fixed; const thresholds = fieldConfig.thresholds; // If thresholds are enabled show each step in the legend if (colorMode === FieldColorModeId.Thresholds && thresholds?.steps && thresholds.steps.length > 1) { return getThresholdItems(fieldConfig, theme); } // If thresholds are enabled show each step in the legend if (colorMode.startsWith('continuous')) { return undefined; // eventually a color bar } let stateColors: Map = new Map(); fields.forEach((field) => { field.values.toArray().forEach((v) => { let state = field.display!(v); stateColors.set(state.text, state.color!); }); }); stateColors.forEach((color, label) => { if (label.length > 0) { items.push({ label: label!, color: theme.visualization.getColorByName(color ?? FALLBACK_COLOR), yAxis: 1, }); } }); return items; } function allNonTimeFields(frames: DataFrame[]): Field[] { const fields: Field[] = []; for (const frame of frames) { for (const field of frame.fields) { if (field.type !== FieldType.time) { fields.push(field); } } } return fields; } export function findNextStateIndex(field: Field, datapointIdx: number) { let end; let rightPointer = datapointIdx + 1; if (rightPointer >= field.values.length) { return null; } while (end === undefined) { if (rightPointer >= field.values.length) { return null; } const rightValue = field.values.get(rightPointer); if (rightValue !== undefined) { end = rightPointer; } else { rightPointer++; } } return end; }