mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
[graph-ng] add temporal DataFrame alignment/outerJoin & move null-asZero pass inside (#29250)
[GraphNG] update uPlot, add temporal DataFrame alignment/outerJoin, move null-asZero pass inside, merge isGap updates into u.setData() calls. Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
parent
1076f47509
commit
917b5c5f2a
@ -71,7 +71,7 @@
|
||||
"react-transition-group": "4.4.1",
|
||||
"slate": "0.47.8",
|
||||
"tinycolor2": "1.4.1",
|
||||
"uplot": "1.3.0"
|
||||
"uplot": "1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "11.0.2",
|
||||
|
21
packages/grafana-ui/src/components/GraphNG/GraphNG.tsx
Normal file → Executable file
21
packages/grafana-ui/src/components/GraphNG/GraphNG.tsx
Normal file → Executable file
@ -7,9 +7,8 @@ import {
|
||||
getFieldColorModeForField,
|
||||
getFieldDisplayName,
|
||||
getTimeField,
|
||||
TIME_SERIES_TIME_FIELD_NAME,
|
||||
} from '@grafana/data';
|
||||
import { alignAndSortDataFramesByFieldName } from './utils';
|
||||
import { mergeTimeSeriesData } from './utils';
|
||||
import { UPlotChart } from '../uPlot/Plot';
|
||||
import { PlotProps } from '../uPlot/types';
|
||||
import { AxisPlacement, getUPlotSideFromAxis, GraphFieldConfig, GraphMode, PointMode } from '../uPlot/config';
|
||||
@ -43,11 +42,11 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
...plotProps
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const alignedData = useMemo(() => alignAndSortDataFramesByFieldName(data, TIME_SERIES_TIME_FIELD_NAME), [data]);
|
||||
const alignedFrameWithGapTest = useMemo(() => mergeTimeSeriesData(data), [data]);
|
||||
const legendItemsRef = useRef<LegendItem[]>([]);
|
||||
const hasLegend = legend && legend.displayMode !== LegendDisplayMode.Hidden;
|
||||
|
||||
if (!alignedData) {
|
||||
if (alignedFrameWithGapTest == null) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
@ -55,10 +54,12 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const alignedFrame = alignedFrameWithGapTest.frame;
|
||||
|
||||
const configBuilder = useMemo(() => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
let { timeIndex } = getTimeField(alignedData);
|
||||
let { timeIndex } = getTimeField(alignedFrame);
|
||||
|
||||
if (timeIndex === undefined) {
|
||||
timeIndex = 0; // assuming first field represents x-domain
|
||||
@ -85,8 +86,8 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
let hasLeftAxis = false;
|
||||
let hasYAxis = false;
|
||||
|
||||
for (let i = 0; i < alignedData.fields.length; i++) {
|
||||
const field = alignedData.fields[i];
|
||||
for (let i = 0; i < alignedFrame.fields.length; i++) {
|
||||
const field = alignedFrame.fields[i];
|
||||
const config = field.config as FieldConfig<GraphFieldConfig>;
|
||||
const customConfig = config.custom || defaultConfig;
|
||||
|
||||
@ -137,7 +138,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
if (hasLegend) {
|
||||
legendItems.push({
|
||||
color: seriesColor,
|
||||
label: getFieldDisplayName(field, alignedData),
|
||||
label: getFieldDisplayName(field, alignedFrame),
|
||||
yAxis: side === AxisPlacement.Right ? 3 : 1,
|
||||
});
|
||||
}
|
||||
@ -147,7 +148,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
|
||||
legendItemsRef.current = legendItems;
|
||||
return builder;
|
||||
}, [alignedData, hasLegend]);
|
||||
}, [alignedFrameWithGapTest, hasLegend]);
|
||||
|
||||
let legendElement: React.ReactElement | undefined;
|
||||
|
||||
@ -163,7 +164,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
<VizLayout width={width} height={height} legend={legendElement}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
<UPlotChart
|
||||
data={alignedData}
|
||||
data={alignedFrameWithGapTest}
|
||||
config={configBuilder}
|
||||
width={vizWidth}
|
||||
height={vizHeight}
|
||||
|
172
packages/grafana-ui/src/components/GraphNG/utils.ts
Normal file → Executable file
172
packages/grafana-ui/src/components/GraphNG/utils.ts
Normal file → Executable file
@ -1,31 +1,155 @@
|
||||
import { DataFrame, FieldType, getTimeField, outerJoinDataFrames, sortDataFrame } from '@grafana/data';
|
||||
import {
|
||||
DataFrame,
|
||||
FieldType,
|
||||
getTimeField,
|
||||
ArrayVector,
|
||||
NullValueMode,
|
||||
getFieldDisplayName,
|
||||
Field,
|
||||
} from '@grafana/data';
|
||||
import { AlignedFrameWithGapTest } from '../uPlot/types';
|
||||
import uPlot, { AlignedData, AlignedDataWithGapTest } from 'uplot';
|
||||
|
||||
// very time oriented for now
|
||||
export const alignAndSortDataFramesByFieldName = (data: DataFrame[], fieldName: string): DataFrame | null => {
|
||||
if (!data.length) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Returns a single DataFrame with:
|
||||
* - A shared time column
|
||||
* - only numeric fields
|
||||
*
|
||||
* The input expects all frames to have a time field with values in ascending order
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export function mergeTimeSeriesData(frames: DataFrame[]): AlignedFrameWithGapTest | null {
|
||||
const valuesFromFrames: AlignedData[] = [];
|
||||
const sourceFields: Field[] = [];
|
||||
|
||||
// normalize time field names
|
||||
// in each frame find first time field and rename it to unified name
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const series = data[i];
|
||||
for (let j = 0; j < series.fields.length; j++) {
|
||||
const field = series.fields[j];
|
||||
if (field.type === FieldType.time) {
|
||||
field.name = fieldName;
|
||||
break;
|
||||
for (const frame of frames) {
|
||||
const { timeField } = getTimeField(frame);
|
||||
if (!timeField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const alignedData: AlignedData = [
|
||||
timeField.values.toArray(), // The x axis (time)
|
||||
];
|
||||
|
||||
// find numeric fields
|
||||
for (const field of frame.fields) {
|
||||
if (field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let values = field.values.toArray();
|
||||
if (field.config.nullValueMode === NullValueMode.AsZero) {
|
||||
values = values.map(v => (v === null ? 0 : v));
|
||||
}
|
||||
alignedData.push(values);
|
||||
|
||||
// Add the first time field
|
||||
if (sourceFields.length < 1) {
|
||||
sourceFields.push(timeField);
|
||||
}
|
||||
|
||||
// This will cache an appropriate field name in the field state
|
||||
getFieldDisplayName(field, frame, frames);
|
||||
sourceFields.push(field);
|
||||
}
|
||||
|
||||
// Timeseries has tima and at least one number
|
||||
if (alignedData.length > 1) {
|
||||
valuesFromFrames.push(alignedData);
|
||||
}
|
||||
}
|
||||
|
||||
const dataFramesToPlot = data.filter(frame => {
|
||||
let { timeIndex } = getTimeField(frame);
|
||||
// filter out series without time index or if the time column is the only one (i.e. after transformations)
|
||||
// won't live long as we gona move out from assuming x === time
|
||||
return timeIndex !== undefined ? frame.fields.length > 1 : false;
|
||||
});
|
||||
if (valuesFromFrames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const aligned = outerJoinDataFrames(dataFramesToPlot, { byField: fieldName })[0];
|
||||
return sortDataFrame(aligned, getTimeField(aligned).timeIndex!);
|
||||
};
|
||||
// do the actual alignment (outerJoin on the first arrays)
|
||||
const { data: alignedData, isGap } = outerJoinValues(valuesFromFrames);
|
||||
|
||||
if (alignedData!.length !== sourceFields.length) {
|
||||
throw new Error('outerJoinValues lost a field?');
|
||||
}
|
||||
|
||||
// Replace the values from the outer-join field
|
||||
return {
|
||||
frame: {
|
||||
length: alignedData![0].length,
|
||||
fields: alignedData!.map((vals, idx) => ({
|
||||
...sourceFields[idx],
|
||||
values: new ArrayVector(vals),
|
||||
})),
|
||||
},
|
||||
isGap,
|
||||
};
|
||||
}
|
||||
|
||||
export function outerJoinValues(tables: AlignedData[]): AlignedDataWithGapTest {
|
||||
if (tables.length === 1) {
|
||||
return {
|
||||
data: tables[0],
|
||||
isGap: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
let xVals: Set<number> = new Set();
|
||||
let xNulls: Array<Set<number>> = [new Set()];
|
||||
|
||||
for (const t of tables) {
|
||||
let xs = t[0];
|
||||
let len = xs.length;
|
||||
let nulls: Set<number> = new Set();
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
xVals.add(xs[i]);
|
||||
}
|
||||
|
||||
for (let j = 1; j < t.length; j++) {
|
||||
let ys = t[j];
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (ys[i] == null) {
|
||||
nulls.add(xs[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xNulls.push(nulls);
|
||||
}
|
||||
|
||||
let data: AlignedData = [Array.from(xVals).sort((a, b) => a - b)];
|
||||
|
||||
let alignedLen = data[0].length;
|
||||
|
||||
let xIdxs = new Map();
|
||||
|
||||
for (let i = 0; i < alignedLen; i++) {
|
||||
xIdxs.set(data[0][i], i);
|
||||
}
|
||||
|
||||
for (const t of tables) {
|
||||
let xs = t[0];
|
||||
|
||||
for (let j = 1; j < t.length; j++) {
|
||||
let ys = t[j];
|
||||
|
||||
let yVals = Array(alignedLen).fill(null);
|
||||
|
||||
for (let i = 0; i < ys.length; i++) {
|
||||
yVals[xIdxs.get(xs[i])] = ys[i];
|
||||
}
|
||||
|
||||
data.push(yVals);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: data,
|
||||
isGap(u: uPlot, seriesIdx: number, dataIdx: number) {
|
||||
// u.data has to be AlignedDate
|
||||
let xVal = u.data[0][dataIdx];
|
||||
return xNulls[seriesIdx].has(xVal!);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
20
packages/grafana-ui/src/components/uPlot/Plot.tsx
Normal file → Executable file
20
packages/grafana-ui/src/components/uPlot/Plot.tsx
Normal file → Executable file
@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
import uPlot from 'uplot';
|
||||
import uPlot, { Options, AlignedData, AlignedDataWithGapTest } from 'uplot';
|
||||
import { buildPlotContext, PlotContext } from './context';
|
||||
import { pluginLog, preparePlotData, shouldInitialisePlot } from './utils';
|
||||
import { pluginLog, shouldInitialisePlot } from './utils';
|
||||
import { usePlotConfig } from './hooks';
|
||||
import { PlotProps } from './types';
|
||||
|
||||
@ -12,7 +12,7 @@ import { PlotProps } from './types';
|
||||
export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [plotInstance, setPlotInstance] = useState<uPlot>();
|
||||
const plotData = useRef<uPlot.AlignedData>();
|
||||
const plotData = useRef<AlignedDataWithGapTest>();
|
||||
|
||||
// uPlot config API
|
||||
const { currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.timeZone, props.config);
|
||||
@ -33,11 +33,12 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
return;
|
||||
}
|
||||
pluginLog('uPlot core', false, 'updating plot data(throttled log!)', plotData.current);
|
||||
|
||||
// If config hasn't changed just update uPlot's data
|
||||
plotInstance.setData(plotData.current);
|
||||
|
||||
if (props.onDataUpdate) {
|
||||
props.onDataUpdate(plotData.current);
|
||||
props.onDataUpdate(plotData.current.data!);
|
||||
}
|
||||
}, [plotInstance, props.onDataUpdate]);
|
||||
|
||||
@ -50,7 +51,10 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
}, [plotInstance]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
plotData.current = preparePlotData(props.data);
|
||||
plotData.current = {
|
||||
data: props.data.frame.fields.map(f => f.values.toArray()) as AlignedData,
|
||||
isGap: props.data.isGap,
|
||||
};
|
||||
}, [props.data]);
|
||||
|
||||
// Decides if plot should update data or re-initialise
|
||||
@ -62,7 +66,7 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
|
||||
// Do nothing if there is data vs series config mismatch. This may happen when the data was updated and made this
|
||||
// effect fire before the config update triggered the effect.
|
||||
if (currentConfig.series.length !== plotData.current.length) {
|
||||
if (currentConfig.series.length !== plotData.current.data!.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -70,7 +74,7 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
if (!canvasRef.current) {
|
||||
throw new Error('Missing Canvas component as a child of the plot.');
|
||||
}
|
||||
const instance = initPlot(plotData.current, currentConfig, canvasRef.current);
|
||||
const instance = initPlot(plotData.current.data!, currentConfig, canvasRef.current);
|
||||
|
||||
if (props.onPlotInit) {
|
||||
props.onPlotInit();
|
||||
@ -106,7 +110,7 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
};
|
||||
|
||||
// Main function initialising uPlot. If final config is not settled it will do nothing
|
||||
function initPlot(data: uPlot.AlignedData, config: uPlot.Options, ref: HTMLDivElement) {
|
||||
function initPlot(data: AlignedData, config: Options, ref: HTMLDivElement) {
|
||||
pluginLog('uPlot core', false, 'initialized with', data, config);
|
||||
return new uPlot(config, data, ref);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { dateTimeFormat, GrafanaTheme, systemDateFormats, TimeZone } from '@grafana/data';
|
||||
import uPlot from 'uplot';
|
||||
import { AxisSide, PlotConfigBuilder } from '../types';
|
||||
import uPlot, { Axis } from 'uplot';
|
||||
import { PlotConfigBuilder } from '../types';
|
||||
import { measureText } from '../../../utils/measureText';
|
||||
|
||||
export interface AxisProps {
|
||||
@ -10,7 +10,7 @@ export interface AxisProps {
|
||||
stroke?: string;
|
||||
show?: boolean;
|
||||
size?: number;
|
||||
side?: AxisSide;
|
||||
side?: Axis.Side;
|
||||
grid?: boolean;
|
||||
formatValue?: (v: any) => string;
|
||||
values?: any;
|
||||
@ -18,8 +18,8 @@ export interface AxisProps {
|
||||
timeZone?: TimeZone;
|
||||
}
|
||||
|
||||
export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, uPlot.Axis> {
|
||||
getConfig(): uPlot.Axis {
|
||||
export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
|
||||
getConfig(): Axis {
|
||||
const {
|
||||
scaleKey,
|
||||
label,
|
||||
@ -35,7 +35,7 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, uPlot.Axis> {
|
||||
const stroke = this.props.stroke || theme.colors.text;
|
||||
const gridColor = theme.isDark ? theme.palette.gray25 : theme.palette.gray90;
|
||||
|
||||
let config: uPlot.Axis = {
|
||||
let config: Axis = {
|
||||
scale: scaleKey,
|
||||
label,
|
||||
show,
|
||||
|
@ -1,7 +1,6 @@
|
||||
// TODO: migrate tests below to the builder
|
||||
|
||||
import { UPlotConfigBuilder } from './UPlotConfigBuilder';
|
||||
import { AxisSide } from '../types';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { expect } from '../../../../../../public/test/lib/common';
|
||||
|
||||
@ -56,7 +55,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
scaleKey: 'scale-x',
|
||||
label: 'test label',
|
||||
timeZone: 'browser',
|
||||
side: AxisSide.Bottom,
|
||||
side: 2,
|
||||
isTime: false,
|
||||
formatValue: () => 'test value',
|
||||
grid: false,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import uPlot from 'uplot';
|
||||
import { Scale } from 'uplot';
|
||||
import { PlotConfigBuilder } from '../types';
|
||||
|
||||
export interface ScaleProps {
|
||||
@ -6,7 +6,7 @@ export interface ScaleProps {
|
||||
isTime?: boolean;
|
||||
}
|
||||
|
||||
export class UPlotScaleBuilder extends PlotConfigBuilder<ScaleProps, uPlot.Scale> {
|
||||
export class UPlotScaleBuilder extends PlotConfigBuilder<ScaleProps, Scale> {
|
||||
getConfig() {
|
||||
const { isTime, scaleKey } = this.props;
|
||||
return {
|
||||
|
4
packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts
Normal file → Executable file
4
packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts
Normal file → Executable file
@ -1,5 +1,5 @@
|
||||
import tinycolor from 'tinycolor2';
|
||||
import uPlot from 'uplot';
|
||||
import { Series } from 'uplot';
|
||||
import { PlotConfigBuilder } from '../types';
|
||||
|
||||
export interface SeriesProps {
|
||||
@ -15,7 +15,7 @@ export interface SeriesProps {
|
||||
fillColor?: string;
|
||||
}
|
||||
|
||||
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, uPlot.Series> {
|
||||
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
||||
getConfig() {
|
||||
const { line, lineColor, lineWidth, points, pointColor, pointSize, fillColor, fillOpacity, scaleKey } = this.props;
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import { PlotPlugin } from './types';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
import { PlotPlugin, AlignedFrameWithGapTest } from './types';
|
||||
import { DataFrame, Field, FieldConfig } from '@grafana/data';
|
||||
|
||||
interface PlotCanvasContextType {
|
||||
@ -23,10 +23,10 @@ interface PlotPluginsContextType {
|
||||
interface PlotContextType extends PlotPluginsContextType {
|
||||
isPlotReady: boolean;
|
||||
getPlotInstance: () => uPlot;
|
||||
getSeries: () => uPlot.Series[];
|
||||
getSeries: () => Series[];
|
||||
getCanvas: () => PlotCanvasContextType;
|
||||
canvasRef: any;
|
||||
data: DataFrame;
|
||||
data: AlignedFrameWithGapTest;
|
||||
}
|
||||
|
||||
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
|
||||
@ -76,7 +76,7 @@ export const usePlotData = (): PlotDataAPI => {
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotData');
|
||||
}
|
||||
return ctx!.data.fields[idx];
|
||||
return ctx!.data.frame.fields[idx];
|
||||
},
|
||||
[ctx]
|
||||
);
|
||||
@ -109,7 +109,7 @@ export const usePlotData = (): PlotDataAPI => {
|
||||
}
|
||||
// by uPlot convention x-axis is always first field
|
||||
// this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html)
|
||||
return ctx!.data.fields.slice(1);
|
||||
return ctx!.data.frame.fields.slice(1);
|
||||
}, [ctx]);
|
||||
|
||||
if (!ctx) {
|
||||
@ -117,7 +117,7 @@ export const usePlotData = (): PlotDataAPI => {
|
||||
}
|
||||
|
||||
return {
|
||||
data: ctx.data,
|
||||
data: ctx.data.frame,
|
||||
getField,
|
||||
getFieldValue,
|
||||
getFieldConfig,
|
||||
@ -129,7 +129,7 @@ export const usePlotData = (): PlotDataAPI => {
|
||||
export const buildPlotContext = (
|
||||
isPlotReady: boolean,
|
||||
canvasRef: any,
|
||||
data: DataFrame,
|
||||
data: AlignedFrameWithGapTest,
|
||||
registerPlugin: any,
|
||||
getPlotInstance: () => uPlot
|
||||
): PlotContextType => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PlotPlugin } from './types';
|
||||
import { pluginLog } from './utils';
|
||||
import uPlot from 'uplot';
|
||||
import uPlot, { Options } from 'uplot';
|
||||
import { getTimeZoneInfo, TimeZone } from '@grafana/data';
|
||||
import { usePlotPluginContext } from './context';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
@ -106,7 +106,7 @@ export const DEFAULT_PLOT_CONFIG = {
|
||||
|
||||
export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, configBuilder: UPlotConfigBuilder) => {
|
||||
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
|
||||
const [currentConfig, setCurrentConfig] = useState<uPlot.Options>();
|
||||
const [currentConfig, setCurrentConfig] = useState<Options>();
|
||||
|
||||
const tzDate = useMemo(() => {
|
||||
let fmt = undefined;
|
||||
|
@ -1,76 +0,0 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
export function renderPlugin({ spikes = 4, outerRadius = 8, innerRadius = 4 } = {}) {
|
||||
outerRadius *= devicePixelRatio;
|
||||
innerRadius *= devicePixelRatio;
|
||||
|
||||
// https://stackoverflow.com/questions/25837158/how-to-draw-a-star-by-using-canvas-html5
|
||||
function drawStar(ctx: any, cx: number, cy: number) {
|
||||
let rot = (Math.PI / 2) * 3;
|
||||
let x = cx;
|
||||
let y = cy;
|
||||
let step = Math.PI / spikes;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - outerRadius);
|
||||
|
||||
for (let i = 0; i < spikes; i++) {
|
||||
x = cx + Math.cos(rot) * outerRadius;
|
||||
y = cy + Math.sin(rot) * outerRadius;
|
||||
ctx.lineTo(x, y);
|
||||
rot += step;
|
||||
|
||||
x = cx + Math.cos(rot) * innerRadius;
|
||||
y = cy + Math.sin(rot) * innerRadius;
|
||||
ctx.lineTo(x, y);
|
||||
rot += step;
|
||||
}
|
||||
|
||||
ctx.lineTo(cx, cy - outerRadius);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawPointsAsStars(u: uPlot, i: number, i0: any, i1: any) {
|
||||
let { ctx } = u;
|
||||
let { stroke, scale } = u.series[i];
|
||||
|
||||
ctx.fillStyle = stroke as string;
|
||||
|
||||
let j = i0;
|
||||
|
||||
while (j <= i1) {
|
||||
const val = u.data[i][j] as number;
|
||||
const cx = Math.round(u.valToPos(u.data[0][j] as number, 'x', true));
|
||||
const cy = Math.round(u.valToPos(val, scale as string, true));
|
||||
|
||||
drawStar(ctx, cx, cy);
|
||||
ctx.fill();
|
||||
|
||||
// const zy = Math.round(u.valToPos(0, scale as string, true));
|
||||
|
||||
// ctx.beginPath();
|
||||
// ctx.lineWidth = 3;
|
||||
// ctx.moveTo(cx, cy - outerRadius);
|
||||
// ctx.lineTo(cx, zy);
|
||||
// ctx.stroke();
|
||||
// ctx.fill();
|
||||
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
opts: (u: uPlot, opts: uPlot.Options) => {
|
||||
opts.series.forEach((s, i) => {
|
||||
if (i > 0) {
|
||||
uPlot.assign(s, {
|
||||
points: {
|
||||
show: drawPointsAsStars,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
hooks: {}, // can add callbacks here
|
||||
};
|
||||
}
|
20
packages/grafana-ui/src/components/uPlot/types.ts
Normal file → Executable file
20
packages/grafana-ui/src/components/uPlot/types.ts
Normal file → Executable file
@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import uPlot, { Options, AlignedData, Series, Hooks } from 'uplot';
|
||||
import { DataFrame, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
|
||||
export type PlotSeriesConfig = Pick<uPlot.Options, 'series' | 'scales' | 'axes'>;
|
||||
export type PlotSeriesConfig = Pick<Options, 'series' | 'scales' | 'axes'>;
|
||||
export type PlotPlugin = {
|
||||
id: string;
|
||||
/** can mutate provided opts as necessary */
|
||||
opts?: (self: uPlot, opts: uPlot.Options) => void;
|
||||
hooks: uPlot.PluginHooks;
|
||||
opts?: (self: uPlot, opts: Options) => void;
|
||||
hooks: Hooks.ArraysOrFuncs;
|
||||
};
|
||||
|
||||
export interface PlotPluginProps {
|
||||
@ -16,7 +16,7 @@ export interface PlotPluginProps {
|
||||
}
|
||||
|
||||
export interface PlotProps {
|
||||
data: DataFrame;
|
||||
data: AlignedFrameWithGapTest;
|
||||
timeRange: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
width: number;
|
||||
@ -24,7 +24,7 @@ export interface PlotProps {
|
||||
config: UPlotConfigBuilder;
|
||||
children?: React.ReactElement[];
|
||||
/** Callback performed when uPlot data is updated */
|
||||
onDataUpdate?: (data: uPlot.AlignedData) => {};
|
||||
onDataUpdate?: (data: AlignedData) => {};
|
||||
/** Callback performed when uPlot is (re)initialized */
|
||||
onPlotInit?: () => {};
|
||||
}
|
||||
@ -34,9 +34,7 @@ export abstract class PlotConfigBuilder<P, T> {
|
||||
abstract getConfig(): T;
|
||||
}
|
||||
|
||||
export enum AxisSide {
|
||||
Top, // 0
|
||||
Right, // 1
|
||||
Bottom, // 2
|
||||
Left, // 3
|
||||
export interface AlignedFrameWithGapTest {
|
||||
frame: DataFrame;
|
||||
isGap: Series.isGap;
|
||||
}
|
||||
|
43
packages/grafana-ui/src/components/uPlot/utils.ts
Normal file → Executable file
43
packages/grafana-ui/src/components/uPlot/utils.ts
Normal file → Executable file
@ -1,8 +1,8 @@
|
||||
import throttle from 'lodash/throttle';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import omit from 'lodash/omit';
|
||||
import { DataFrame, FieldType, getTimeField, rangeUtil, RawTimeRange } from '@grafana/data';
|
||||
import uPlot from 'uplot';
|
||||
import { rangeUtil, RawTimeRange } from '@grafana/data';
|
||||
import { Options } from 'uplot';
|
||||
import { PlotPlugin, PlotProps } from './types';
|
||||
|
||||
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
|
||||
@ -16,7 +16,7 @@ export function rangeToMinMax(timeRange: RawTimeRange): [number, number] {
|
||||
return [v.from.valueOf() / 1000, v.to.valueOf() / 1000];
|
||||
}
|
||||
|
||||
export const buildPlotConfig = (props: PlotProps, plugins: Record<string, PlotPlugin>): uPlot.Options => {
|
||||
export const buildPlotConfig = (props: PlotProps, plugins: Record<string, PlotPlugin>): Options => {
|
||||
return {
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
@ -38,40 +38,7 @@ export const buildPlotConfig = (props: PlotProps, plugins: Record<string, PlotPl
|
||||
} as any;
|
||||
};
|
||||
|
||||
export const preparePlotData = (data: DataFrame): uPlot.AlignedData => {
|
||||
const plotData: any[] = [];
|
||||
|
||||
// Prepare x axis
|
||||
let { timeIndex } = getTimeField(data);
|
||||
let xvals = data.fields[timeIndex!].values.toArray();
|
||||
|
||||
if (!isNaN(timeIndex!)) {
|
||||
xvals = xvals.map(v => v / 1000);
|
||||
}
|
||||
|
||||
plotData.push(xvals);
|
||||
|
||||
for (let i = 0; i < data.fields.length; i++) {
|
||||
const field = data.fields[i];
|
||||
|
||||
// already handled time and we ignore non-numeric fields
|
||||
if (i === timeIndex || field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let values = field.values.toArray();
|
||||
|
||||
if (field.config.custom?.nullValues === 'asZero') {
|
||||
values = values.map(v => (v === null ? 0 : v));
|
||||
}
|
||||
|
||||
plotData.push(values);
|
||||
}
|
||||
|
||||
return plotData;
|
||||
};
|
||||
|
||||
const isPlottingTime = (config: uPlot.Options) => {
|
||||
const isPlottingTime = (config: Options) => {
|
||||
let isTimeSeries = false;
|
||||
|
||||
if (!config.scales) {
|
||||
@ -93,7 +60,7 @@ const isPlottingTime = (config: uPlot.Options) => {
|
||||
* Based on two config objects indicates whether or not uPlot needs reinitialisation
|
||||
* This COULD be done based on data frames, but keeping it this way for now as a simplification
|
||||
*/
|
||||
export const shouldInitialisePlot = (prevConfig?: uPlot.Options, config?: uPlot.Options) => {
|
||||
export const shouldInitialisePlot = (prevConfig?: Options, config?: Options) => {
|
||||
if (!config && !prevConfig) {
|
||||
return false;
|
||||
}
|
||||
|
@ -26919,10 +26919,10 @@ update-notifier@^2.5.0:
|
||||
semver-diff "^2.0.0"
|
||||
xdg-basedir "^3.0.0"
|
||||
|
||||
uplot@1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.3.0.tgz#c805e632c9dc0a2f47041fa0431996cbb42de83a"
|
||||
integrity sha512-15EIwgOYdTeX6YXRJK6u3sq/gtFFa8ICdQROTeQBStmekhGgl8MixhL6pO66pmxPuzaJUrfIa+o5gvzttMF5rw==
|
||||
uplot@1.4.4:
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.4.4.tgz#ab30da26b1e2d432df5bc43389e70399787f0448"
|
||||
integrity sha512-vgV84+by3fGTU4bdpffSvA9FX8ide6MsmlBzOASPDdZCquXmCA+T2qodeNdnBen+7YOeqD9H91epVnF0dQgVKw==
|
||||
|
||||
upper-case-first@^1.1.0, upper-case-first@^1.1.2:
|
||||
version "1.1.2"
|
||||
|
Loading…
Reference in New Issue
Block a user