123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648 |
- import React from 'react';
- import uPlot from 'uplot';
- import {
- ArrayVector,
- DataFrame,
- DashboardCursorSync,
- DataHoverPayload,
- DataHoverEvent,
- DataHoverClearEvent,
- FALLBACK_COLOR,
- Field,
- FieldColorModeId,
- FieldConfig,
- FieldType,
- formattedValueToString,
- getFieldDisplayName,
- getValueFormat,
- GrafanaTheme2,
- getActiveThreshold,
- Threshold,
- getFieldConfigWithMinMax,
- ThresholdsMode,
- TimeRange,
- } from '@grafana/data';
- import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames';
- import { VizLegendOptions, AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema';
- import {
- FIXED_UNIT,
- SeriesVisibilityChangeMode,
- UPlotConfigBuilder,
- UPlotConfigPrepFn,
- VizLegendItem,
- } from '@grafana/ui';
- import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold';
- import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue';
- import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
- import { preparePlotData2, getStackingGroups } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
- import { getConfig, TimelineCoreOptions } from './timeline';
- import { TimelineFieldConfig, TimelineOptions } from './types';
- 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 const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
- frame,
- theme,
- timeZone,
- getTimeRange,
- mode,
- eventBus,
- sync,
- rowHeight,
- colWidth,
- showValue,
- alignValue,
- mergeValues,
- getValueColor,
- }) => {
- const builder = new UPlotConfigBuilder(timeZone);
- const xScaleUnit = 'time';
- const xScaleKey = 'x';
- const isDiscrete = (field: Field) => {
- const mode = field.config?.color?.mode;
- return !(mode && field.display && mode.startsWith('continuous-'));
- };
- const getValueColorFn = (seriesIdx: number, value: any) => {
- const field = frame.fields[seriesIdx];
- if (
- field.state?.origin?.fieldIndex !== undefined &&
- field.state?.origin?.frameIndex !== undefined &&
- getValueColor
- ) {
- return getValueColor(field.state?.origin?.frameIndex, field.state?.origin?.fieldIndex, value);
- }
- return FALLBACK_COLOR;
- };
- const opts: TimelineCoreOptions = {
- // should expose in panel config
- mode: mode!,
- numSeries: frame.fields.length - 1,
- isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]),
- mergeValues,
- rowHeight: rowHeight!,
- colWidth: colWidth,
- showValue: showValue!,
- alignValue,
- theme,
- label: (seriesIdx) => getFieldDisplayName(frame.fields[seriesIdx], frame),
- getFieldConfig: (seriesIdx) => frame.fields[seriesIdx].config.custom,
- getValueColor: getValueColorFn,
- 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);
- const payload: DataHoverPayload = {
- point: {
- [xScaleUnit]: null,
- [FIXED_UNIT]: null,
- },
- data: frame,
- };
- 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((frames) => preparePlotData2(frames[0], getStackingGroups(frames[0])));
- builder.setCursor(coreConfig.cursor);
- builder.addScale({
- scaleKey: xScaleKey,
- 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: xScaleKey,
- 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: { show: 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<TimelineFieldConfig>;
- 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,
- });
- }
- if (sync && sync() !== DashboardCursorSync.Off) {
- let cursor: Partial<uPlot.Cursor> = {};
- cursor.sync = {
- key: '__global_',
- filters: {
- pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
- if (sync && sync() === DashboardCursorSync.Off) {
- return false;
- }
- payload.rowIndex = dataIdx;
- if (x < 0 && y < 0) {
- payload.point[xScaleUnit] = null;
- payload.point[FIXED_UNIT] = null;
- eventBus.publish(new DataHoverClearEvent());
- } else {
- payload.point[xScaleUnit] = src.posToVal(x, xScaleKey);
- payload.point.panelRelY = y > 0 ? y / h : 1; // used for old graph panel to position tooltip
- payload.down = undefined;
- eventBus.publish(new DataHoverEvent(payload));
- }
- return true;
- },
- },
- //TODO: remove any once https://github.com/leeoniya/uPlot/pull/611 got merged or the typing is fixed
- scales: [xScaleKey, null as any],
- };
- builder.setSync();
- builder.setCursor(cursor);
- }
- return builder;
- };
- export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
- const names = new Map<string, number>();
- 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;
- }
- function getSpanNulls(field: Field) {
- let spanNulls = field.config.custom?.spanNulls;
- // magic value for join() to leave nulls alone instead of expanding null ranges
- // should be set to -1 when spanNulls = null|undefined|false|0, which is "retain nulls, without expanding"
- // Infinity is not optimal here since it causes spanNulls to be more expensive than simply removing all nulls unconditionally
- return !spanNulls ? -1 : spanNulls === true ? Infinity : spanNulls;
- }
- /**
- * 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<Threshold, string>();
- const textToColor = new Map<string, string>();
- 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 input = field.values.toArray();
- const vals = new Array<String | undefined>(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;
- } else {
- vals[i] = thresholdToText.get(getActiveThreshold(v, thresholds.steps));
- }
- }
- return {
- ...field,
- config: {
- ...field.config,
- custom: {
- ...field.config.custom,
- spanNulls: getSpanNulls(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,
- timeRange: TimeRange,
- 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;
- let maybeSortedFrame = maybeSortFrame(
- frame,
- frame.fields.findIndex((f) => f.type === FieldType.time)
- );
- let nulledFrame = applyNullInsertThreshold({
- frame: maybeSortedFrame,
- refFieldPseudoMin: timeRange.from.valueOf(),
- refFieldPseudoMax: timeRange.to.valueOf(),
- });
- if (nulledFrame !== frame) {
- changed = true;
- }
- const fields: Field[] = [];
- for (let field of nullToValue(nulledFrame).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,
- spanNulls: getSpanNulls(field),
- },
- },
- };
- fields.push(field);
- break;
- default:
- changed = true;
- }
- }
- if (isTimeseries && fields.length > 1) {
- hasTimeseries = true;
- if (changed) {
- frames.push({
- ...maybeSortedFrame,
- fields,
- });
- } else {
- frames.push(maybeSortedFrame);
- }
- }
- }
- 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(step.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;
- }
- return getFieldLegendItem(allNonTimeFields(frames), theme);
- }
- export function getFieldLegendItem(fields: Field[], theme: GrafanaTheme2): VizLegendItem[] | undefined {
- 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<string, string | undefined> = new Map();
- fields.forEach((field) => {
- field.values.toArray().forEach((v) => {
- let state = field.display!(v);
- if (state.color) {
- 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;
- }
- const startValue = field.values.get(datapointIdx);
- while (end === undefined) {
- if (rightPointer >= field.values.length) {
- return null;
- }
- const rightValue = field.values.get(rightPointer);
- if (rightValue === undefined || rightValue === startValue) {
- rightPointer++;
- } else {
- end = rightPointer;
- }
- }
- return end;
- }
- /**
- * Returns the precise duration of a time range passed in milliseconds.
- * This function calculates with 30 days month and 365 days year.
- * adapted from https://gist.github.com/remino/1563878
- * @param milliSeconds The duration in milliseconds
- * @returns A formated string of the duration
- */
- export function fmtDuration(milliSeconds: number): string {
- if (milliSeconds < 0 || Number.isNaN(milliSeconds)) {
- return '';
- }
- let yr: number, mo: number, wk: number, d: number, h: number, m: number, s: number, ms: number;
- s = Math.floor(milliSeconds / 1000);
- m = Math.floor(s / 60);
- s = s % 60;
- h = Math.floor(m / 60);
- m = m % 60;
- d = Math.floor(h / 24);
- h = h % 24;
- yr = Math.floor(d / 365);
- if (yr > 0) {
- d = d % 365;
- }
- mo = Math.floor(d / 30);
- if (mo > 0) {
- d = d % 30;
- }
- wk = Math.floor(d / 7);
- if (wk > 0) {
- d = d % 7;
- }
- ms = Math.round((milliSeconds % 1000) * 1000) / 1000;
- return (
- yr > 0
- ? yr + 'y ' + (mo > 0 ? mo + 'mo ' : '') + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
- : mo > 0
- ? mo + 'mo ' + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
- : wk > 0
- ? wk + 'w ' + (d > 0 ? d + 'd ' : '')
- : d > 0
- ? d + 'd ' + (h > 0 ? h + 'h ' : '')
- : h > 0
- ? h + 'h ' + (m > 0 ? m + 'm ' : '')
- : m > 0
- ? m + 'm ' + (s > 0 ? s + 's ' : '')
- : s > 0
- ? s + 's ' + (ms > 0 ? ms + 'ms ' : '')
- : ms > 0
- ? ms + 'ms '
- : '0'
- ).trim();
- }
|