mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Graph NG WIP (#27572)
* Squash merge Ryans uPlot work * uPlot - wrap into composable API * Remove MicroPlot.tsx * Add missing uPlot stylesheet import * Use field config for axes config * Min selection distance for Zoom * WIP Tooltip behaviour * Some progress on rendering plot * gdev dashboard for graph ng tests * Update custom field config interface for graph options * Add support for paths in default field config setup (+2 squashed commits) Squashed commits: [93fc3afbfc] Typecheck fix [a07ef86a8b] Add support for paths in default field config setup * Include IANA timezone canonical name in TimeZoneInfo * Use correct time zone on time axis * Default axis width * Use system date time formats for time axis ticks * Graph panel layout * Respect config paths when rendering default value of field config property * Fix mismatch in field config editor types * Color field option editor * Refactor plot context to a single one * First version of new graph legend plugin * Fix mutable data frame * Multiple ui fixes, layout is still slightly problematic * Remove unused * Fix tooltip test * Some perf optimisations * Update dev dashboard * Typecheck fix * Do not use color value editor as standard property editor, add custom field config to graph panel to control series color * Update dev dashboard with correct tags * Fix undefined issues * Update devenv/dev-dashboards/panel-graph/graph-ng.json
This commit is contained in:
parent
033feebbf9
commit
844dd9e8f0
2253
devenv/dev-dashboards/panel-graph/graph-ng.json
Normal file
2253
devenv/dev-dashboards/panel-graph/graph-ng.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -137,7 +137,6 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
||||
// Anything in the field config that's not set by the datasource
|
||||
// will be filled in by panel's field configuration
|
||||
setFieldConfigDefaults(config, source.defaults, context);
|
||||
|
||||
// Find any matching rules and then override
|
||||
for (const rule of override) {
|
||||
if (rule.match(field, frame, options.data!)) {
|
||||
@ -286,9 +285,9 @@ const processFieldConfigValue = (
|
||||
context: FieldOverrideEnv
|
||||
) => {
|
||||
const currentConfig = get(destination, fieldConfigProperty.path);
|
||||
|
||||
if (currentConfig === null || currentConfig === undefined) {
|
||||
const item = context.fieldConfigRegistry.getIfExists(fieldConfigProperty.id);
|
||||
// console.log(item);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
UnitFieldConfigSettings,
|
||||
unitOverrideProcessor,
|
||||
} from '../field';
|
||||
import { FieldColor } from '../types';
|
||||
|
||||
/**
|
||||
* Fluent API for declarative creation of field config option editors
|
||||
@ -91,7 +92,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
|
||||
}
|
||||
|
||||
addColorPicker<TSettings = any>(
|
||||
config: FieldConfigEditorConfig<TOptions, TSettings & ColorFieldConfigSettings, string>
|
||||
config: FieldConfigEditorConfig<TOptions, TSettings & ColorFieldConfigSettings, FieldColor>
|
||||
) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
|
@ -67,7 +67,8 @@
|
||||
"react-table": "7.0.0",
|
||||
"react-transition-group": "4.3.0",
|
||||
"slate": "0.47.8",
|
||||
"tinycolor2": "1.4.1"
|
||||
"tinycolor2": "1.4.1",
|
||||
"uplot": "1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "11.0.2",
|
||||
|
@ -93,7 +93,7 @@ describe('Chart Tooltip', () => {
|
||||
// +--------------------++------+
|
||||
// |origin|
|
||||
// +------+
|
||||
expect(styleAttribute).toContain('translate3d(890px, 590px, 0)');
|
||||
expect(styleAttribute).toContain('translate3d(910px, 610px, 0)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -5,7 +5,7 @@ import { Dimensions, TimeZone } from '@grafana/data';
|
||||
import { FlotPosition } from '../Graph/types';
|
||||
import { TooltipContainer } from './TooltipContainer';
|
||||
|
||||
export type TooltipMode = 'single' | 'multi';
|
||||
export type TooltipMode = 'single' | 'multi' | 'none';
|
||||
|
||||
// Describes active dimensions user interacts with
|
||||
// It's a key-value pair where:
|
||||
|
@ -45,17 +45,17 @@ export const TooltipContainer: React.FC<TooltipContainerProps> = ({ position, of
|
||||
const xOverflow = width - (position.x + measurement.width);
|
||||
const yOverflow = height - (position.y + measurement.height);
|
||||
if (xOverflow < 0) {
|
||||
xO = measurement.width + offset.x;
|
||||
xO = measurement.width;
|
||||
}
|
||||
|
||||
if (yOverflow < 0) {
|
||||
yO = measurement.height + offset.y;
|
||||
yO = measurement.height;
|
||||
}
|
||||
}
|
||||
|
||||
setPlacement({
|
||||
x: position.x - xO,
|
||||
y: position.y - yO,
|
||||
x: position.x + offset.x - xO,
|
||||
y: position.y + offset.y - yO,
|
||||
});
|
||||
}, [tooltipRef, position]);
|
||||
|
||||
|
@ -24,7 +24,7 @@ export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps>
|
||||
onLabelClick,
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
const styles = getStyles(theme);
|
||||
return (
|
||||
<>
|
||||
<LegendSeriesIcon
|
||||
@ -44,11 +44,7 @@ export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps>
|
||||
onLabelClick(item, event);
|
||||
}
|
||||
}}
|
||||
className={css`
|
||||
cursor: pointer;
|
||||
white-space: pre-wrap;
|
||||
color: ${!item.isVisible && theme.colors.linkDisabled};
|
||||
`}
|
||||
className={cx(styles.label, !item.isVisible && styles.labelDisabled)}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
@ -61,6 +57,7 @@ export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps>
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
row: css`
|
||||
label: LegendRow;
|
||||
font-size: ${theme.typography.size.sm};
|
||||
td {
|
||||
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
|
||||
@ -68,9 +65,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
}
|
||||
`,
|
||||
label: css`
|
||||
label: LegendLabel;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
labelDisabled: css`
|
||||
label: LegendLabelDisabled;
|
||||
color: ${theme.colors.linkDisabled};
|
||||
`,
|
||||
itemWrapper: css`
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
|
@ -5,7 +5,7 @@ import { css, cx } from 'emotion';
|
||||
import { SeriesIcon } from '../../Legend/SeriesIcon';
|
||||
import { useTheme } from '../../../themes';
|
||||
|
||||
interface SeriesTableRowProps {
|
||||
export interface SeriesTableRowProps {
|
||||
color?: string;
|
||||
label?: string;
|
||||
value: string | GraphSeriesValue;
|
||||
|
@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
FieldConfigEditorProps,
|
||||
ColorFieldConfigSettings,
|
||||
GrafanaTheme,
|
||||
getColorFromHexRgbOrName,
|
||||
FieldColor,
|
||||
} from '@grafana/data';
|
||||
import { ColorPicker } from '../ColorPicker/ColorPicker';
|
||||
import { getTheme, stylesFactory } from '../../themes';
|
||||
@ -11,7 +12,8 @@ import { Icon } from '../Icon/Icon';
|
||||
import { css } from 'emotion';
|
||||
import { ColorPickerTrigger } from '../ColorPicker/ColorPickerTrigger';
|
||||
|
||||
export const ColorValueEditor: React.FC<FieldConfigEditorProps<string, ColorFieldConfigSettings>> = ({
|
||||
// Supporting FixedColor only currently
|
||||
export const ColorValueEditor: React.FC<FieldConfigEditorProps<FieldColor, ColorFieldConfigSettings>> = ({
|
||||
value,
|
||||
onChange,
|
||||
item,
|
||||
@ -20,10 +22,17 @@ export const ColorValueEditor: React.FC<FieldConfigEditorProps<string, ColorFiel
|
||||
const theme = getTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
const color = value || (item.defaultValue as string) || theme.colors.panelBg;
|
||||
const color = value.fixedColor || item.defaultValue?.fixedColor;
|
||||
|
||||
const onValueChange = useCallback(
|
||||
color => {
|
||||
onChange({ ...value, fixedColor: color });
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<ColorPicker color={color} onChange={onChange} enableNamedColors={!settings?.disableNamedColors}>
|
||||
<ColorPicker color={color || ''} onChange={onValueChange} enableNamedColors={!settings?.disableNamedColors}>
|
||||
{({ ref, showColorPicker, hideColorPicker }) => {
|
||||
return (
|
||||
<div className={styles.spot} onBlur={hideColorPicker}>
|
||||
@ -32,11 +41,11 @@ export const ColorValueEditor: React.FC<FieldConfigEditorProps<string, ColorFiel
|
||||
ref={ref}
|
||||
onClick={showColorPicker}
|
||||
onMouseLeave={hideColorPicker}
|
||||
color={getColorFromHexRgbOrName(color, theme.type)}
|
||||
color={color ? getColorFromHexRgbOrName(color, theme.type) : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.colorText} onClick={showColorPicker}>
|
||||
{value ?? settings?.textWhenUndefined ?? 'Pick Color'}
|
||||
{color ?? settings?.textWhenUndefined ?? 'Pick Color'}
|
||||
</div>
|
||||
{value && settings?.allowUndefined && (
|
||||
<Icon className={styles.trashIcon} name="trash-alt" onClick={() => onChange(undefined)} />
|
||||
|
@ -69,6 +69,11 @@ export {
|
||||
BigValueTextMode,
|
||||
} from './BigValue/BigValue';
|
||||
|
||||
export { GraphCustomFieldConfig } from './uPlot/types';
|
||||
export { UPlotChart } from './uPlot/Plot';
|
||||
export { Canvas } from './uPlot/Canvas';
|
||||
export * from './uPlot/plugins';
|
||||
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { GraphLegend } from './Graph/GraphLegend';
|
||||
|
20
packages/grafana-ui/src/components/uPlot/Canvas.tsx
Normal file
20
packages/grafana-ui/src/components/uPlot/Canvas.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { usePlotContext } from './context';
|
||||
|
||||
interface CanvasProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Ref element to render the uPlot canvas to
|
||||
// This is a required child of Plot component!
|
||||
export const Canvas: React.FC<CanvasProps> = ({ width, height }) => {
|
||||
const plot = usePlotContext();
|
||||
if (!plot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div ref={plot.canvasRef} />;
|
||||
};
|
||||
|
||||
Canvas.displayName = 'Canvas';
|
142
packages/grafana-ui/src/components/uPlot/Plot.tsx
Normal file
142
packages/grafana-ui/src/components/uPlot/Plot.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import uPlot from 'uplot';
|
||||
import { useTheme } from '../../themes';
|
||||
import { buildPlotContext, PlotContext } from './context';
|
||||
import { buildPlotConfig, pluginLog, preparePlotData, shouldReinitialisePlot } from './utils';
|
||||
import { usePlotPlugins } from './hooks';
|
||||
import { PlotProps } from './types';
|
||||
|
||||
// uPlot abstraction responsible for plot initialisation, setup and refresh
|
||||
// Receives a data frame that is x-axis aligned, as of https://github.com/leeoniya/uPlot/tree/master/docs#data-format
|
||||
// Exposes contexts for plugins registration and uPlot instance access
|
||||
export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
const theme = useTheme();
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// instance of uPlot, exposed via PlotContext
|
||||
const [plotInstance, setPlotInstance] = useState<uPlot>();
|
||||
|
||||
// Array with current plot data points, calculated when data frame is passed to a plot
|
||||
// const [plotData, setPlotData] = useState<uPlot.AlignedData>();
|
||||
// uPlot config
|
||||
const [currentPlotConfig, setCurrentPlotConfig] = useState<uPlot.Options>();
|
||||
|
||||
// uPlot plugins API hook
|
||||
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
|
||||
|
||||
// Main function initialising uPlot. If final config is not settled it will do nothing
|
||||
// Will destroy existing uPlot instance
|
||||
const initPlot = useCallback(() => {
|
||||
if (!currentPlotConfig || !canvasRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (plotInstance) {
|
||||
pluginLog('uPlot core', false, 'destroying existing instance due to reinitialisation');
|
||||
plotInstance.destroy();
|
||||
}
|
||||
|
||||
const data = preparePlotData(props.data);
|
||||
|
||||
pluginLog('uPlot core', false, 'initialized with', data, currentPlotConfig);
|
||||
|
||||
setPlotInstance(new uPlot(currentPlotConfig, data, canvasRef.current));
|
||||
}, [props, currentPlotConfig, arePluginsReady, canvasRef.current, plotInstance]);
|
||||
|
||||
const hasConfigChanged = useCallback(() => {
|
||||
const config = buildPlotConfig(props, props.data, plugins, theme);
|
||||
if (!currentPlotConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return shouldReinitialisePlot(currentPlotConfig, config);
|
||||
}, [props, props.data, currentPlotConfig]);
|
||||
|
||||
// Initialise uPlot when config changes
|
||||
useEffect(() => {
|
||||
if (!currentPlotConfig) {
|
||||
return;
|
||||
}
|
||||
initPlot();
|
||||
}, [currentPlotConfig]);
|
||||
|
||||
// Destroy uPlot on when components unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (plotInstance) {
|
||||
pluginLog('uPlot core', false, 'destroying existing instance due to unmount');
|
||||
plotInstance.destroy();
|
||||
}
|
||||
};
|
||||
}, [plotInstance]);
|
||||
|
||||
// Effect performed when all plugins have registered. Final config is set triggering plot initialisation
|
||||
useEffect(() => {
|
||||
if (!canvasRef) {
|
||||
throw new Error('Cannot render graph without canvas! Render Canvas as a child of Plot component.');
|
||||
}
|
||||
|
||||
if (!arePluginsReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvasRef.current) {
|
||||
setCurrentPlotConfig(buildPlotConfig(props, props.data, plugins, theme));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (plotInstance) {
|
||||
console.log('uPlot - destroy instance, unmount');
|
||||
plotInstance.destroy();
|
||||
}
|
||||
};
|
||||
}, [arePluginsReady]);
|
||||
|
||||
// When data changes try to be clever about config updates, needs some more love
|
||||
useEffect(() => {
|
||||
const data = preparePlotData(props.data);
|
||||
const config = buildPlotConfig(props, props.data, plugins, theme);
|
||||
|
||||
// See if series configs changes, re-initialise if necessary
|
||||
// this is a minimal check, need to update for field config cleverness ;)
|
||||
if (hasConfigChanged()) {
|
||||
setCurrentPlotConfig(config); // will trigger uPlot reinitialisation
|
||||
return;
|
||||
} else {
|
||||
pluginLog('uPlot core', true, 'updating plot data(throttled log!)');
|
||||
// If config hasn't changed just update uPlot's data
|
||||
plotInstance?.setData(data);
|
||||
}
|
||||
}, [props.data, props.timeRange]);
|
||||
|
||||
// When size props changed update plot size synchronously
|
||||
useLayoutEffect(() => {
|
||||
if (plotInstance) {
|
||||
plotInstance.setSize({
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
});
|
||||
}
|
||||
}, [plotInstance, props.width, props.height]);
|
||||
|
||||
// Memoize plot context
|
||||
const plotCtx = useMemo(() => {
|
||||
return buildPlotContext(registerPlugin, canvasRef, props.data, plotInstance);
|
||||
}, [registerPlugin, canvasRef, props.data, plotInstance]);
|
||||
|
||||
return (
|
||||
<PlotContext.Provider value={plotCtx}>
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
width: ${props.width}px;
|
||||
height: ${props.height}px;
|
||||
`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</PlotContext.Provider>
|
||||
);
|
||||
};
|
162
packages/grafana-ui/src/components/uPlot/context.ts
Normal file
162
packages/grafana-ui/src/components/uPlot/context.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import { PlotPlugin } from './types';
|
||||
import { DataFrame, Field, FieldConfig } from '@grafana/data';
|
||||
|
||||
interface PlotCanvasContextType {
|
||||
// canvas size css pxs
|
||||
width: number;
|
||||
height: number;
|
||||
// plotting area bbox, css pxs
|
||||
plot: {
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PlotContextType {
|
||||
u?: uPlot;
|
||||
series?: uPlot.Series[];
|
||||
canvas?: PlotCanvasContextType;
|
||||
canvasRef: any;
|
||||
registerPlugin: (plugin: PlotPlugin) => () => void;
|
||||
data: DataFrame;
|
||||
}
|
||||
|
||||
type PlotPluginsContextType = {
|
||||
registerPlugin: (plugin: PlotPlugin) => () => void;
|
||||
};
|
||||
|
||||
export const PlotContext = React.createContext<PlotContextType | null>(null);
|
||||
|
||||
// Exposes uPlot instance and bounding box of the entire canvas and plot area
|
||||
export const usePlotContext = (): PlotContextType | null => {
|
||||
return useContext<PlotContextType | null>(PlotContext);
|
||||
};
|
||||
|
||||
const throwWhenNoContext = (name: string) => {
|
||||
throw new Error(`${name} must be used within PlotContext`);
|
||||
};
|
||||
|
||||
// Exposes API for registering uPlot plugins
|
||||
export const usePlotPluginContext = (): PlotPluginsContextType => {
|
||||
const ctx = useContext(PlotContext);
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotPluginContext');
|
||||
}
|
||||
return {
|
||||
registerPlugin: ctx!.registerPlugin,
|
||||
};
|
||||
};
|
||||
|
||||
interface PlotDataAPI {
|
||||
/** Data frame passed to graph, x-axis aligned */
|
||||
data: DataFrame;
|
||||
/** Returns field by index */
|
||||
getField: (idx: number) => Field;
|
||||
/** Returns x-axis fields */
|
||||
getXAxisFields: () => Field[];
|
||||
/** Returns x-axis fields */
|
||||
getYAxisFields: () => Field[];
|
||||
/** Returns field value by field and value index */
|
||||
getFieldValue: (fieldIdx: number, rowIdx: number) => any;
|
||||
/** Returns field config by field index */
|
||||
getFieldConfig: (fieldIdx: number) => FieldConfig;
|
||||
}
|
||||
|
||||
export const usePlotData = (): PlotDataAPI => {
|
||||
const ctx = useContext(PlotContext);
|
||||
|
||||
const getField = useCallback(
|
||||
(idx: number) => {
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotData');
|
||||
}
|
||||
return ctx!.data.fields[idx];
|
||||
},
|
||||
[ctx]
|
||||
);
|
||||
|
||||
const getFieldConfig = useCallback(
|
||||
(idx: number) => {
|
||||
const field: Field = getField(idx);
|
||||
return field.config;
|
||||
},
|
||||
[ctx]
|
||||
);
|
||||
|
||||
const getFieldValue = useCallback(
|
||||
(fieldIdx: number, rowIdx: number) => {
|
||||
const field: Field = getField(fieldIdx);
|
||||
return field.values.get(rowIdx);
|
||||
},
|
||||
[ctx]
|
||||
);
|
||||
|
||||
const getXAxisFields = useCallback(() => {
|
||||
// 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 [getField(0)];
|
||||
}, [ctx]);
|
||||
|
||||
const getYAxisFields = useCallback(() => {
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotData');
|
||||
}
|
||||
// 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);
|
||||
}, [ctx]);
|
||||
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotData');
|
||||
}
|
||||
|
||||
return {
|
||||
data: ctx!.data,
|
||||
getField,
|
||||
getFieldValue,
|
||||
getFieldConfig,
|
||||
getXAxisFields,
|
||||
getYAxisFields,
|
||||
};
|
||||
};
|
||||
|
||||
// Returns bbox of the plot canvas (only the graph, no axes)
|
||||
export const usePlotCanvas = (): PlotCanvasContextType | null => {
|
||||
const ctx = usePlotContext();
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotCanvas');
|
||||
}
|
||||
|
||||
return ctx!.canvas || null;
|
||||
};
|
||||
|
||||
export const buildPlotContext = (
|
||||
registerPlugin: any,
|
||||
canvasRef: any,
|
||||
data: DataFrame,
|
||||
u?: uPlot
|
||||
): PlotContextType | null => {
|
||||
return {
|
||||
u,
|
||||
series: u?.series,
|
||||
canvas: u
|
||||
? {
|
||||
width: u.width,
|
||||
height: u.height,
|
||||
plot: {
|
||||
width: u.bbox.width / window.devicePixelRatio,
|
||||
height: u.bbox.height / window.devicePixelRatio,
|
||||
top: u.bbox.top / window.devicePixelRatio,
|
||||
left: u.bbox.left / window.devicePixelRatio,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
registerPlugin,
|
||||
canvasRef,
|
||||
data,
|
||||
};
|
||||
};
|
77
packages/grafana-ui/src/components/uPlot/hooks.ts
Normal file
77
packages/grafana-ui/src/components/uPlot/hooks.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { PlotPlugin } from './types';
|
||||
import { pluginLog } from './utils';
|
||||
|
||||
export const usePlotPlugins = () => {
|
||||
/**
|
||||
* Map of registered plugins (via children)
|
||||
* Used to build uPlot plugins config
|
||||
*/
|
||||
const [plugins, setPlugins] = useState<Record<string, PlotPlugin>>({});
|
||||
// const registeredPlugins = useRef(0);
|
||||
|
||||
// arePluginsReady determines whether or not all plugins has already registered and uPlot should be initialised
|
||||
const [arePluginsReady, setPluginsReady] = useState(false);
|
||||
|
||||
const cancellationToken = useRef<number>();
|
||||
|
||||
const checkPluginsReady = useCallback(() => {
|
||||
if (cancellationToken.current) {
|
||||
window.cancelAnimationFrame(cancellationToken.current);
|
||||
}
|
||||
|
||||
/**
|
||||
* After registering plugin let's wait for all code to complete to set arePluginsReady to true.
|
||||
* If any other plugin will try to register, the previously scheduled call will be canceled
|
||||
* and arePluginsReady will be deferred to next animation frame.
|
||||
*/
|
||||
cancellationToken.current = window.requestAnimationFrame(function() {
|
||||
setPluginsReady(true);
|
||||
});
|
||||
}, [cancellationToken, setPluginsReady]);
|
||||
|
||||
const registerPlugin = useCallback(
|
||||
(plugin: PlotPlugin) => {
|
||||
pluginLog(plugin.id, false, 'register');
|
||||
|
||||
if (plugins.hasOwnProperty(plugin.id)) {
|
||||
throw new Error(`${plugin.id} that is already registered`);
|
||||
}
|
||||
|
||||
setPlugins(plugs => {
|
||||
return {
|
||||
...plugs,
|
||||
[plugin.id]: plugin,
|
||||
};
|
||||
});
|
||||
checkPluginsReady();
|
||||
|
||||
return () => {
|
||||
setPlugins(p => {
|
||||
pluginLog(plugin.id, false, 'unregister');
|
||||
delete p[plugin.id];
|
||||
return {
|
||||
...p,
|
||||
};
|
||||
});
|
||||
};
|
||||
},
|
||||
[setPlugins]
|
||||
);
|
||||
|
||||
// When uPlot mounts let's check if there are any plugins pending registration
|
||||
useEffect(() => {
|
||||
checkPluginsReady();
|
||||
return () => {
|
||||
if (cancellationToken.current) {
|
||||
window.cancelAnimationFrame(cancellationToken.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
arePluginsReady,
|
||||
plugins,
|
||||
registerPlugin,
|
||||
};
|
||||
};
|
@ -0,0 +1,56 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { SelectionPlugin } from './SelectionPlugin';
|
||||
import { css } from 'emotion';
|
||||
import { Button } from '../../Button';
|
||||
import useClickAway from 'react-use/lib/useClickAway';
|
||||
|
||||
interface AnnotationsEditorPluginProps {
|
||||
onAnnotationCreate: () => void;
|
||||
}
|
||||
|
||||
export const AnnotationsEditorPlugin: React.FC<AnnotationsEditorPluginProps> = ({ onAnnotationCreate }) => {
|
||||
const pluginId = 'AnnotationsEditorPlugin';
|
||||
|
||||
return (
|
||||
<SelectionPlugin
|
||||
id={pluginId}
|
||||
onSelect={selection => {
|
||||
console.log(selection);
|
||||
}}
|
||||
lazy
|
||||
>
|
||||
{({ selection, clearSelection }) => {
|
||||
return <AnnotationEditor selection={selection} onClose={clearSelection} />;
|
||||
}}
|
||||
</SelectionPlugin>
|
||||
);
|
||||
};
|
||||
|
||||
const AnnotationEditor: React.FC<any> = ({ onClose, selection }) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
useClickAway(ref, () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={ref}
|
||||
className={css`
|
||||
position: absolute;
|
||||
background: purple;
|
||||
top: ${selection.bbox.top}px;
|
||||
left: ${selection.bbox.left}px;
|
||||
width: ${selection.bbox.width}px;
|
||||
height: ${selection.bbox.height}px;
|
||||
`}
|
||||
>
|
||||
Annotations editor maybe?
|
||||
<Button onClick={() => {}}>Create annotation</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
126
packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx
Normal file
126
packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Global, css as cssCore } from '@emotion/core';
|
||||
|
||||
import { PlotPluginProps } from '../types';
|
||||
import { usePlotPluginContext } from '../context';
|
||||
import { pluginLog } from '../utils';
|
||||
import { CursorPlugin } from './CursorPlugin';
|
||||
|
||||
interface ClickPluginAPI {
|
||||
point: { seriesIdx: number | null; dataIdx: number | null };
|
||||
coords: {
|
||||
// coords relative to plot canvas, css px
|
||||
plotCanvas: Coords;
|
||||
// coords relative to viewport , css px
|
||||
viewport: Coords;
|
||||
};
|
||||
// coords relative to plot canvas, css px
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
interface ClickPluginProps extends PlotPluginProps {
|
||||
onClick: (e: { seriesIdx: number | null; dataIdx: number | null }) => void;
|
||||
children: (api: ClickPluginAPI) => React.ReactElement | null;
|
||||
}
|
||||
|
||||
interface Coords {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Exposes API for Graph click interactions
|
||||
export const ClickPlugin: React.FC<ClickPluginProps> = ({ id, onClick, children }) => {
|
||||
const pluginId = `ClickPlugin:${id}`;
|
||||
|
||||
const pluginsApi = usePlotPluginContext();
|
||||
const [point, setPoint] = useState<{ seriesIdx: number | null; dataIdx: number | null } | null>(null);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
pluginLog(pluginId, false, 'clearing click selection');
|
||||
setPoint(null);
|
||||
}, [setPoint]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = pluginsApi.registerPlugin({
|
||||
id: pluginId,
|
||||
hooks: {
|
||||
init: u => {
|
||||
pluginLog(pluginId, false, 'init');
|
||||
|
||||
// for naive click&drag check
|
||||
let isClick = false;
|
||||
// REF: https://github.com/leeoniya/uPlot/issues/239
|
||||
let pts = Array.from(u.root.querySelectorAll<HTMLDivElement>('.u-cursor-pt'));
|
||||
const plotCanvas = u.root.querySelector<HTMLDivElement>('.u-over');
|
||||
|
||||
plotCanvas!.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
isClick = true;
|
||||
});
|
||||
plotCanvas!.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
isClick = false;
|
||||
});
|
||||
|
||||
// TODO: remove listeners on unmount
|
||||
plotCanvas!.addEventListener('mouseup', (e: MouseEvent) => {
|
||||
if (!isClick) {
|
||||
setPoint(null);
|
||||
return;
|
||||
}
|
||||
isClick = true;
|
||||
pluginLog(pluginId, false, 'canvas click');
|
||||
|
||||
if (e.target) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.classList.contains('u-cursor-pt')) {
|
||||
setPoint({ seriesIdx: null, dataIdx: null });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (pts.length > 0) {
|
||||
pts.forEach((pt, i) => {
|
||||
// TODO: remove listeners on unmount
|
||||
pt.addEventListener('click', e => {
|
||||
const seriesIdx = i + 1;
|
||||
const dataIdx = u.cursor.idx;
|
||||
|
||||
pluginLog(id, false, seriesIdx, dataIdx);
|
||||
setPoint({ seriesIdx, dataIdx: dataIdx || null });
|
||||
onClick({ seriesIdx, dataIdx: dataIdx || null });
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
unregister();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Global
|
||||
styles={cssCore`
|
||||
.uplot .u-cursor-pt {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<CursorPlugin id={pluginId} capture="mousedown" lock>
|
||||
{({ coords }) => {
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children({
|
||||
point,
|
||||
coords,
|
||||
clearSelection,
|
||||
});
|
||||
}}
|
||||
</CursorPlugin>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,69 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { ClickPlugin } from './ClickPlugin';
|
||||
import { Portal } from '../../Portal/Portal';
|
||||
import { css } from 'emotion';
|
||||
import useClickAway from 'react-use/lib/useClickAway';
|
||||
|
||||
interface ContextMenuPluginProps {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({ onClose }) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
setIsOpen(!isOpen);
|
||||
}, [setIsOpen]);
|
||||
|
||||
return (
|
||||
<ClickPlugin id="ContextMenu" onClick={onClick}>
|
||||
{({ point, coords, clearSelection }) => {
|
||||
return (
|
||||
<Portal>
|
||||
<ContextMenu
|
||||
selection={{ point, coords }}
|
||||
onClose={() => {
|
||||
clearSelection();
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
);
|
||||
}}
|
||||
</ClickPlugin>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContextMenuProps {
|
||||
onClose?: () => void;
|
||||
selection: any;
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ onClose, selection }) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
useClickAway(ref, () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={css`
|
||||
background: yellow;
|
||||
position: absolute;
|
||||
// rendering in Portal, hence using viewport coords
|
||||
top: ${selection.coords.viewport.y + 10}px;
|
||||
left: ${selection.coords.viewport.x + 10}px;
|
||||
`}
|
||||
>
|
||||
Point: {JSON.stringify(selection.point)} <br />
|
||||
Viewport coords: {JSON.stringify(selection.coords.viewport)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,112 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { PlotPluginProps } from '../types';
|
||||
import { pluginLog } from '../utils';
|
||||
import { usePlotPluginContext } from '../context';
|
||||
|
||||
interface CursorPluginAPI {
|
||||
focusedSeriesIdx: number | null;
|
||||
focusedPointIdx: number | null;
|
||||
|
||||
coords: {
|
||||
// coords relative to plot canvas, css px
|
||||
plotCanvas: Coords;
|
||||
// coords relative to viewport , css px
|
||||
viewport: Coords;
|
||||
};
|
||||
}
|
||||
|
||||
interface CursorPluginProps extends PlotPluginProps {
|
||||
onMouseMove?: () => void; // anything?
|
||||
children: (api: CursorPluginAPI) => React.ReactElement | null;
|
||||
// on what interaction position should be captures
|
||||
capture?: 'mousemove' | 'mousedown';
|
||||
// should the position be persisted when user leaves canvas area
|
||||
lock?: boolean;
|
||||
}
|
||||
|
||||
interface Coords {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Exposes API for Graph cursor position
|
||||
export const CursorPlugin: React.FC<CursorPluginProps> = ({ id, children, capture = 'mousemove', lock = false }) => {
|
||||
const pluginId = `CursorPlugin:${id}`;
|
||||
const plotCanvas = useRef<HTMLDivElement>(null);
|
||||
const plotCanvasBBox = useRef<any>({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 });
|
||||
const pluginsApi = usePlotPluginContext();
|
||||
|
||||
// state exposed to the consumers, maybe better implement as CursorPlugin?
|
||||
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||
const [coords, setCoords] = useState<{ viewport: Coords; plotCanvas: Coords } | null>(null);
|
||||
|
||||
const clearCoords = useCallback(() => {
|
||||
setCoords(null);
|
||||
}, [setCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
pluginLog(pluginId, true, `Focused series: ${focusedSeriesIdx}, focused point: ${focusedPointIdx}`);
|
||||
}, [focusedPointIdx, focusedSeriesIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (plotCanvas && plotCanvas.current) {
|
||||
plotCanvasBBox.current = plotCanvas.current.getBoundingClientRect();
|
||||
}
|
||||
}, [plotCanvas.current]);
|
||||
|
||||
// on mount - init plugin
|
||||
useEffect(() => {
|
||||
const onMouseCapture = (e: MouseEvent) => {
|
||||
setCoords({
|
||||
plotCanvas: {
|
||||
x: e.clientX - plotCanvasBBox.current.left,
|
||||
y: e.clientY - plotCanvasBBox.current.top,
|
||||
},
|
||||
viewport: {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const unregister = pluginsApi.registerPlugin({
|
||||
id: pluginId,
|
||||
hooks: {
|
||||
init: u => {
|
||||
// @ts-ignore
|
||||
plotCanvas.current = u.root.querySelector<HTMLDivElement>('.u-over');
|
||||
// @ts-ignore
|
||||
plotCanvas.current.addEventListener(capture, onMouseCapture);
|
||||
if (!lock) {
|
||||
// @ts-ignore
|
||||
plotCanvas.current.addEventListener('mouseleave', clearCoords);
|
||||
}
|
||||
},
|
||||
setCursor: u => {
|
||||
setFocusedPointIdx(u.cursor.idx === undefined ? null : u.cursor.idx);
|
||||
},
|
||||
setSeries: (u, idx) => {
|
||||
setFocusedSeriesIdx(idx);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (plotCanvas && plotCanvas.current) {
|
||||
plotCanvas.current.removeEventListener(capture, onMouseCapture);
|
||||
}
|
||||
unregister();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// only render children if we are interacting with the canvas
|
||||
return coords
|
||||
? children({
|
||||
focusedSeriesIdx,
|
||||
focusedPointIdx,
|
||||
coords,
|
||||
})
|
||||
: null;
|
||||
};
|
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { GraphCustomFieldConfig, GraphLegend, LegendDisplayMode, LegendItem } from '../..';
|
||||
import { usePlotData } from '../context';
|
||||
import { FieldType, getColorFromHexRgbOrName, getFieldDisplayName } from '@grafana/data';
|
||||
import { colors } from '../../../utils';
|
||||
|
||||
export type LegendPlacement = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
interface LegendPluginProps {
|
||||
placement: LegendPlacement;
|
||||
displayMode?: LegendDisplayMode;
|
||||
}
|
||||
|
||||
export const LegendPlugin: React.FC<LegendPluginProps> = ({ placement, displayMode = LegendDisplayMode.List }) => {
|
||||
const { data } = usePlotData();
|
||||
|
||||
const legendItems: LegendItem[] = [];
|
||||
|
||||
let seriesIdx = 0;
|
||||
|
||||
for (let i = 0; i < data.fields.length; i++) {
|
||||
const field = data.fields[i];
|
||||
|
||||
if (field.type === FieldType.time) {
|
||||
continue;
|
||||
}
|
||||
legendItems.push({
|
||||
color:
|
||||
field.config.color && field.config.color.fixedColor
|
||||
? getColorFromHexRgbOrName(field.config.color.fixedColor)
|
||||
: colors[seriesIdx],
|
||||
label: getFieldDisplayName(field, data),
|
||||
isVisible: true,
|
||||
//flot vs uPlot differences
|
||||
yAxis: (field.config.custom as GraphCustomFieldConfig).axis?.side === 1 ? 3 : 1,
|
||||
});
|
||||
seriesIdx++;
|
||||
}
|
||||
|
||||
return (
|
||||
<GraphLegend
|
||||
placement={placement === 'top' || placement === 'bottom' ? 'under' : 'right'}
|
||||
items={legendItems}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,88 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { PlotPluginProps } from '../types';
|
||||
import { usePlotCanvas, usePlotPluginContext } from '../context';
|
||||
import { pluginLog } from '../utils';
|
||||
|
||||
interface Selection {
|
||||
min: number;
|
||||
max: number;
|
||||
|
||||
// selection bounding box, relative to canvas
|
||||
bbox: {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SelectionPluginAPI {
|
||||
selection: Selection;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
interface SelectionPluginProps extends PlotPluginProps {
|
||||
onSelect: (selection: Selection) => void;
|
||||
onDismiss?: () => void;
|
||||
// when true onSelect won't be fired when selection ends
|
||||
// useful for plugins that need to do sth with the selected region, i.e. annotations editor
|
||||
lazy?: boolean;
|
||||
children?: (api: SelectionPluginAPI) => JSX.Element;
|
||||
}
|
||||
|
||||
export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDismiss, lazy, id, children }) => {
|
||||
const pluginId = `SelectionPlugin:${id}`;
|
||||
const pluginsApi = usePlotPluginContext();
|
||||
const canvas = usePlotCanvas();
|
||||
|
||||
const [selection, setSelection] = useState<Selection | null>(null);
|
||||
//
|
||||
useEffect(() => {
|
||||
if (!lazy && selection) {
|
||||
pluginLog(pluginId, false, 'selected', selection);
|
||||
onSelect(selection);
|
||||
}
|
||||
}, [selection]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelection(null);
|
||||
}, [setSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
pluginsApi.registerPlugin({
|
||||
id: pluginId,
|
||||
hooks: {
|
||||
setSelect: u => {
|
||||
const min = u.posToVal(u.select.left, 'x');
|
||||
const max = u.posToVal(u.select.left + u.select.width, 'x');
|
||||
|
||||
setSelection({
|
||||
min,
|
||||
max,
|
||||
bbox: {
|
||||
left: u.bbox.left / window.devicePixelRatio + u.select.left,
|
||||
top: u.bbox.top / window.devicePixelRatio,
|
||||
height: u.bbox.height / window.devicePixelRatio,
|
||||
width: u.select.width,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!children || !canvas || !selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children({
|
||||
selection,
|
||||
clearSelection,
|
||||
});
|
||||
};
|
@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Portal } from '../../Portal/Portal';
|
||||
import { usePlotContext, usePlotData } from '../context';
|
||||
import { CursorPlugin } from './CursorPlugin';
|
||||
import { SeriesTable, SeriesTableRowProps } from '../../Graph/GraphTooltip/SeriesTable';
|
||||
import { FieldType, formattedValueToString, getDisplayProcessor, getFieldDisplayName, TimeZone } from '@grafana/data';
|
||||
import { TooltipContainer } from '../../Chart/TooltipContainer';
|
||||
import { TooltipMode } from '../../Chart/Tooltip';
|
||||
|
||||
interface TooltipPluginProps {
|
||||
mode?: TooltipMode;
|
||||
timeZone: TimeZone;
|
||||
}
|
||||
|
||||
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone }) => {
|
||||
const pluginId = 'PlotTooltip';
|
||||
const plotContext = usePlotContext();
|
||||
const { data, getField, getXAxisFields } = usePlotData();
|
||||
|
||||
const xAxisFields = getXAxisFields();
|
||||
// assuming single x-axis
|
||||
const xAxisField = xAxisFields[0];
|
||||
const xAxisFmt = xAxisField.display || getDisplayProcessor({ field: xAxisField, timeZone });
|
||||
|
||||
return (
|
||||
<CursorPlugin id={pluginId}>
|
||||
{({ focusedSeriesIdx, focusedPointIdx, coords }) => {
|
||||
if (!plotContext || !plotContext.series) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tooltip = null;
|
||||
|
||||
// when no no cursor interaction
|
||||
if (focusedPointIdx === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// when interacting with a point in single mode
|
||||
if (mode === 'single' && focusedSeriesIdx !== null) {
|
||||
const xVal = xAxisFmt(xAxisFields[0]!.values.get(focusedPointIdx)).text;
|
||||
const field = getField(focusedSeriesIdx);
|
||||
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone });
|
||||
tooltip = (
|
||||
<SeriesTable
|
||||
series={[
|
||||
{
|
||||
// stroke is typed as CanvasRenderingContext2D['strokeStyle'] - we are using strings only for now
|
||||
color: plotContext.series![focusedSeriesIdx!].stroke as string,
|
||||
label: getFieldDisplayName(field, data),
|
||||
value: fieldFmt(field.values.get(focusedPointIdx)).text,
|
||||
},
|
||||
]}
|
||||
timestamp={xVal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'multi') {
|
||||
const xVal = xAxisFmt(xAxisFields[0].values.get(focusedPointIdx)).text;
|
||||
tooltip = (
|
||||
<SeriesTable
|
||||
series={data.fields.reduce<SeriesTableRowProps[]>((agg, f, i) => {
|
||||
// skipping time field and non-numeric fields
|
||||
if (f.type === FieldType.time || f.type !== FieldType.number) {
|
||||
return agg;
|
||||
}
|
||||
|
||||
return [
|
||||
...agg,
|
||||
{
|
||||
// stroke is typed as CanvasRenderingContext2D['strokeStyle'] - we are using strings only for now
|
||||
color: plotContext.series![i].stroke as string,
|
||||
label: getFieldDisplayName(f, data),
|
||||
value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))),
|
||||
isActive: focusedSeriesIdx === i,
|
||||
},
|
||||
];
|
||||
}, [])}
|
||||
timestamp={xVal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tooltip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<TooltipContainer position={{ x: coords.viewport.x, y: coords.viewport.y }} offset={{ x: 10, y: 10 }}>
|
||||
{tooltip}
|
||||
</TooltipContainer>
|
||||
</Portal>
|
||||
);
|
||||
}}
|
||||
</CursorPlugin>
|
||||
);
|
||||
};
|
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { SelectionPlugin } from './SelectionPlugin';
|
||||
|
||||
interface ZoomPluginProps {
|
||||
onZoom: (range: { from: number; to: number }) => void;
|
||||
}
|
||||
|
||||
// min px width that triggers zoom
|
||||
const MIN_ZOOM_DIST = 5;
|
||||
|
||||
export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom }) => {
|
||||
return (
|
||||
<SelectionPlugin
|
||||
id="Zoom"
|
||||
/* very time series oriented for now */
|
||||
onSelect={selection => {
|
||||
if (selection.bbox.width < MIN_ZOOM_DIST) {
|
||||
return;
|
||||
}
|
||||
onZoom({ from: selection.min * 1000, to: selection.max * 1000 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
export { ClickPlugin } from './ClickPlugin';
|
||||
export { SelectionPlugin } from './SelectionPlugin';
|
||||
export { ZoomPlugin } from './ZoomPlugin';
|
||||
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
|
||||
export { ContextMenuPlugin } from './ContextMenuPlugin';
|
||||
export { TooltipPlugin } from './TooltipPlugin';
|
||||
export { LegendPlugin } from './LegendPlugin';
|
76
packages/grafana-ui/src/components/uPlot/renderPlugin.ts
Normal file
76
packages/grafana-ui/src/components/uPlot/renderPlugin.ts
Normal file
@ -0,0 +1,76 @@
|
||||
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
|
||||
};
|
||||
}
|
63
packages/grafana-ui/src/components/uPlot/types.ts
Normal file
63
packages/grafana-ui/src/components/uPlot/types.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import { DataFrame, FieldColor, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { NullValuesMode } from '../../../../../public/app/plugins/panel/graph3/types';
|
||||
|
||||
export enum MicroPlotAxisSide {
|
||||
top = 0,
|
||||
right = 1,
|
||||
bottom = 2,
|
||||
left = 3,
|
||||
}
|
||||
|
||||
interface AxisConfig {
|
||||
label: string;
|
||||
side: number;
|
||||
grid: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface LineConfig {
|
||||
show: boolean;
|
||||
width: number;
|
||||
color: FieldColor;
|
||||
}
|
||||
interface PointConfig {
|
||||
show: boolean;
|
||||
radius: number;
|
||||
}
|
||||
interface BarsConfig {
|
||||
show: boolean;
|
||||
}
|
||||
interface FillConfig {
|
||||
alpha: number;
|
||||
}
|
||||
|
||||
export interface GraphCustomFieldConfig {
|
||||
axis: AxisConfig;
|
||||
line: LineConfig;
|
||||
points: PointConfig;
|
||||
bars: BarsConfig;
|
||||
fill: FillConfig;
|
||||
nullValues: NullValuesMode;
|
||||
}
|
||||
|
||||
export type PlotPlugin = {
|
||||
id: string;
|
||||
/** can mutate provided opts as necessary */
|
||||
opts?: (self: uPlot, opts: uPlot.Options) => void;
|
||||
hooks: uPlot.PluginHooks;
|
||||
};
|
||||
|
||||
export interface PlotPluginProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PlotProps {
|
||||
data: DataFrame;
|
||||
width: number;
|
||||
height: number;
|
||||
timeRange: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
children: React.ReactNode[];
|
||||
}
|
15
packages/grafana-ui/src/components/uPlot/utils.test.ts
Normal file
15
packages/grafana-ui/src/components/uPlot/utils.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { timeFormatToTemplate } from './utils';
|
||||
|
||||
describe('timeFormatToTemplate', () => {
|
||||
it.each`
|
||||
format | expected
|
||||
${'HH:mm:ss'} | ${'{HH}:{mm}:{ss}'}
|
||||
${'HH:mm'} | ${'{HH}:{mm}'}
|
||||
${'MM/DD HH:mm'} | ${'{MM}/{DD} {HH}:{mm}'}
|
||||
${'MM/DD'} | ${'{MM}/{DD}'}
|
||||
${'YYYY-MM'} | ${'{YYYY}-{MM}'}
|
||||
${'YYYY'} | ${'{YYYY}'}
|
||||
`('should convert $format to $expected', ({ format, expected }) => {
|
||||
expect(timeFormatToTemplate(format)).toEqual(expected);
|
||||
});
|
||||
});
|
307
packages/grafana-ui/src/components/uPlot/utils.ts
Normal file
307
packages/grafana-ui/src/components/uPlot/utils.ts
Normal file
@ -0,0 +1,307 @@
|
||||
import throttle from 'lodash/throttle';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import omit from 'lodash/omit';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import {
|
||||
DataFrame,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getColorFromHexRgbOrName,
|
||||
getFieldDisplayName,
|
||||
getTimeField,
|
||||
getTimeZoneInfo,
|
||||
GrafanaTheme,
|
||||
rangeUtil,
|
||||
RawTimeRange,
|
||||
systemDateFormats,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { colors } from '../../utils';
|
||||
import uPlot from 'uplot';
|
||||
import { GraphCustomFieldConfig, PlotPlugin, PlotProps } from './types';
|
||||
|
||||
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
|
||||
|
||||
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;
|
||||
export const timeFormatToTemplate = (f: string) => {
|
||||
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, match => `{${match}}`);
|
||||
};
|
||||
|
||||
const timeStampsConfig = [
|
||||
[3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'],
|
||||
[3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'],
|
||||
[
|
||||
3600 * 24,
|
||||
`{${timeFormatToTemplate(systemDateFormats.interval.day)}`,
|
||||
7,
|
||||
`${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`,
|
||||
],
|
||||
[
|
||||
3600,
|
||||
`{${timeFormatToTemplate(systemDateFormats.interval.minute)}`,
|
||||
4,
|
||||
`${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate(
|
||||
systemDateFormats.interval.day
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
60,
|
||||
`{${timeFormatToTemplate(systemDateFormats.interval.second)}`,
|
||||
4,
|
||||
`${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate(
|
||||
systemDateFormats.interval.day
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
1,
|
||||
`:{ss}`,
|
||||
2,
|
||||
`:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate(
|
||||
systemDateFormats.interval.minute
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
1e-3,
|
||||
':{ss}.{fff}',
|
||||
2,
|
||||
`:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate(
|
||||
systemDateFormats.interval.minute
|
||||
)}`,
|
||||
],
|
||||
];
|
||||
|
||||
export function rangeToMinMax(timeRange: RawTimeRange): [number, number] {
|
||||
const v = rangeUtil.convertRawToRange(timeRange);
|
||||
return [v.from.valueOf() / 1000, v.to.valueOf() / 1000];
|
||||
}
|
||||
|
||||
// based on aligned data frames creates config for scales, axes and series
|
||||
export const buildSeriesConfig = (
|
||||
data: DataFrame,
|
||||
timeRange: TimeRange,
|
||||
theme: GrafanaTheme
|
||||
): {
|
||||
series: uPlot.Series[];
|
||||
scales: Record<string, uPlot.Scale>;
|
||||
axes: uPlot.Axis[];
|
||||
} => {
|
||||
const series: uPlot.Series[] = [{}];
|
||||
const scales: Record<string, uPlot.Scale> = {
|
||||
x: {
|
||||
time: true,
|
||||
// range: rangeToMinMax(timeRange.raw),
|
||||
// auto: true
|
||||
},
|
||||
};
|
||||
|
||||
const axes: uPlot.Axis[] = [];
|
||||
|
||||
let { timeIndex } = getTimeField(data);
|
||||
|
||||
if (timeIndex === undefined) {
|
||||
timeIndex = 0; // assuming first field represents x-domain
|
||||
scales.x.time = false;
|
||||
}
|
||||
|
||||
// x-axis
|
||||
axes.push({
|
||||
show: true,
|
||||
stroke: theme.colors.text,
|
||||
grid: {
|
||||
show: true,
|
||||
stroke: theme.palette.gray4,
|
||||
width: 1 / devicePixelRatio,
|
||||
},
|
||||
values: timeStampsConfig,
|
||||
});
|
||||
|
||||
let seriesIdx = 0;
|
||||
|
||||
for (let i = 0; i < data.fields.length; i++) {
|
||||
const field = data.fields[i];
|
||||
const config = field.config as FieldConfig<GraphCustomFieldConfig>;
|
||||
const customConfig = config.custom;
|
||||
console.log(customConfig);
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
|
||||
if (i === timeIndex || field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scale = config.unit || '__fixed';
|
||||
|
||||
if (!scales[scale]) {
|
||||
scales[scale] = {};
|
||||
axes.push({
|
||||
scale,
|
||||
label: config.custom?.axis?.label,
|
||||
show: true,
|
||||
size: config.custom?.axis?.width || 80,
|
||||
stroke: theme.colors.text,
|
||||
side: config.custom?.axis?.side || 3,
|
||||
grid: {
|
||||
show: config.custom?.axis?.grid,
|
||||
stroke: theme.palette.gray4,
|
||||
width: 1 / devicePixelRatio,
|
||||
},
|
||||
values: (u, vals) => vals.map(v => formattedValueToString(fmt(v))),
|
||||
});
|
||||
}
|
||||
|
||||
const seriesColor =
|
||||
customConfig?.line?.color && customConfig?.line?.color.fixedColor
|
||||
? getColorFromHexRgbOrName(customConfig.line?.color.fixedColor)
|
||||
: colors[seriesIdx];
|
||||
|
||||
series.push({
|
||||
scale,
|
||||
label: getFieldDisplayName(field, data),
|
||||
stroke: seriesColor,
|
||||
fill: customConfig?.fill?.alpha
|
||||
? tinycolor(seriesColor)
|
||||
.setAlpha(customConfig?.fill?.alpha)
|
||||
.toRgbString()
|
||||
: undefined,
|
||||
width: customConfig?.line?.show ? customConfig?.line?.width || 1 : 0,
|
||||
points: {
|
||||
show: customConfig?.points?.show,
|
||||
size: customConfig?.points?.radius || 5,
|
||||
},
|
||||
spanGaps: customConfig?.nullValues === 'connected',
|
||||
});
|
||||
seriesIdx += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
scales,
|
||||
series,
|
||||
axes,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildPlotConfig = (
|
||||
props: PlotProps,
|
||||
data: DataFrame,
|
||||
plugins: Record<string, PlotPlugin>,
|
||||
theme: GrafanaTheme
|
||||
): uPlot.Options => {
|
||||
const seriesConfig = buildSeriesConfig(data, props.timeRange, theme);
|
||||
let tzDate;
|
||||
|
||||
// When plotting time series use correct timezone for timestamps
|
||||
if (seriesConfig.scales.x.time) {
|
||||
const tz = getTimeZoneInfo(props.timeZone, Date.now())?.ianaName;
|
||||
if (tz) {
|
||||
tzDate = (ts: number) => uPlot.tzDate(new Date(ts * 1e3), tz);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
focus: {
|
||||
alpha: 1,
|
||||
},
|
||||
cursor: {
|
||||
focus: {
|
||||
prox: 30,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
plugins: Object.entries(plugins).map(p => ({
|
||||
hooks: p[1].hooks,
|
||||
})),
|
||||
hooks: {},
|
||||
tzDate,
|
||||
...seriesConfig,
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 shouldReinitialisePlot = (prevConfig: uPlot.Options, config: uPlot.Options) => {
|
||||
// reinitialise when number of series, scales or axes changes
|
||||
if (
|
||||
prevConfig.series?.length !== config.series?.length ||
|
||||
prevConfig.axes?.length !== config.axes?.length ||
|
||||
prevConfig.scales?.length !== config.scales?.length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
|
||||
// reinitialise when any of the series config changes
|
||||
if (config.series && prevConfig.series) {
|
||||
for (const series of config.series) {
|
||||
if (!isEqual(series, prevConfig.series[idx])) {
|
||||
return true;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.axes && prevConfig.axes) {
|
||||
idx = 0;
|
||||
for (const axis of config.axes) {
|
||||
// Comparing axes config, skipping values property as it changes across config builds - probably need to be more clever
|
||||
if (!isEqual(omit(axis, 'values'), omit(prevConfig.axes[idx], 'values'))) {
|
||||
return true;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Dev helpers
|
||||
export const throttledLog = throttle((...t: any[]) => {
|
||||
console.log(...t);
|
||||
}, 500);
|
||||
|
||||
export const pluginLog = (id: string, throttle = false, ...t: any[]) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return;
|
||||
}
|
||||
const fn = throttle ? throttledLog : console.log;
|
||||
fn(`[Plugin: ${id}]: `, ...t);
|
||||
};
|
@ -18,6 +18,7 @@ import {
|
||||
valueMappingsOverrideProcessor,
|
||||
ThresholdsMode,
|
||||
TimeZone,
|
||||
FieldColor,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { Switch } from '../components/Switch/Switch';
|
||||
@ -211,8 +212,8 @@ export const getStandardFieldConfigs = () => {
|
||||
// settings: {
|
||||
// placeholder: '-',
|
||||
// },
|
||||
// shouldApply: () => true,
|
||||
// category: ['Color & thresholds'],
|
||||
// shouldApply: field => field.type !== FieldType.time,
|
||||
// category,
|
||||
// };
|
||||
|
||||
return [unit, min, max, decimals, displayName, noValue, thresholds, mappings, links];
|
||||
@ -285,7 +286,7 @@ export const getStandardOptionEditors = () => {
|
||||
editor: ValueMappingsValueEditor as any,
|
||||
};
|
||||
|
||||
const color: StandardEditorsRegistryItem<string> = {
|
||||
const color: StandardEditorsRegistryItem<FieldColor> = {
|
||||
id: 'color',
|
||||
name: 'Color',
|
||||
description: 'Allows color selection',
|
||||
@ -323,9 +324,9 @@ export const getStandardOptionEditors = () => {
|
||||
mappings,
|
||||
thresholds,
|
||||
links,
|
||||
color,
|
||||
statsPicker,
|
||||
strings,
|
||||
timeZone,
|
||||
color,
|
||||
];
|
||||
};
|
||||
|
@ -102,6 +102,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
||||
};
|
||||
|
||||
onFieldConfigChange = (config: FieldConfigSource) => {
|
||||
console.log(config);
|
||||
const { panel } = this.props;
|
||||
|
||||
panel.updateFieldConfig({
|
||||
|
@ -38,6 +38,7 @@ const azureMonitorPlugin = async () =>
|
||||
|
||||
import * as textPanel from 'app/plugins/panel/text/module';
|
||||
import * as graph2Panel from 'app/plugins/panel/graph2/module';
|
||||
import * as graph3Panel from 'app/plugins/panel/graph3/module';
|
||||
import * as graphPanel from 'app/plugins/panel/graph/module';
|
||||
import * as dashListPanel from 'app/plugins/panel/dashlist/module';
|
||||
import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module';
|
||||
@ -79,6 +80,7 @@ const builtInPlugins: any = {
|
||||
|
||||
'app/plugins/panel/text/module': textPanel,
|
||||
'app/plugins/panel/graph2/module': graph2Panel,
|
||||
'app/plugins/panel/graph3/module': graph3Panel,
|
||||
'app/plugins/panel/graph/module': graphPanel,
|
||||
'app/plugins/panel/dashlist/module': dashListPanel,
|
||||
'app/plugins/panel/pluginlist/module': pluginsListPanel,
|
||||
|
82
public/app/plugins/panel/graph3/GraphPanel.tsx
Normal file
82
public/app/plugins/panel/graph3/GraphPanel.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
ContextMenuPlugin,
|
||||
TooltipPlugin,
|
||||
UPlotChart,
|
||||
ZoomPlugin,
|
||||
LegendPlugin,
|
||||
Canvas,
|
||||
LegendDisplayMode,
|
||||
} from '@grafana/ui';
|
||||
import { PanelProps } from '@grafana/data';
|
||||
import { Options } from './types';
|
||||
import { alignAndSortDataFramesByFieldName } from './utils';
|
||||
import { VizLayout } from './VizLayout';
|
||||
|
||||
interface GraphPanelProps extends PanelProps<Options> {}
|
||||
|
||||
const TIME_FIELD_NAME = 'Time';
|
||||
|
||||
export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
data,
|
||||
timeRange,
|
||||
timeZone,
|
||||
width,
|
||||
height,
|
||||
options,
|
||||
onChangeTimeRange,
|
||||
}) => {
|
||||
const alignedData = useMemo(() => {
|
||||
if (!data || !data.series?.length) {
|
||||
return null;
|
||||
}
|
||||
return alignAndSortDataFramesByFieldName(data.series, TIME_FIELD_NAME);
|
||||
}, [data]);
|
||||
|
||||
if (!alignedData) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VizLayout width={width} height={height}>
|
||||
{({ builder, getLayout }) => {
|
||||
const layout = getLayout();
|
||||
// when all layout slots are ready we can calculate the canvas(actual viz) size
|
||||
const canvasSize = layout.isReady
|
||||
? {
|
||||
width: width - (layout.left.width + layout.right.width),
|
||||
height: height - (layout.top.height + layout.bottom.height),
|
||||
}
|
||||
: { width: 0, height: 0 };
|
||||
|
||||
if (options.legend.isVisible) {
|
||||
builder.addSlot(
|
||||
options.legend.placement,
|
||||
<LegendPlugin
|
||||
placement={options.legend.placement}
|
||||
displayMode={options.legend.asTable ? LegendDisplayMode.Table : LegendDisplayMode.List}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
builder.clearSlot(options.legend.placement);
|
||||
}
|
||||
|
||||
return (
|
||||
<UPlotChart data={alignedData} timeRange={timeRange} timeZone={timeZone} {...canvasSize}>
|
||||
{builder.addSlot('canvas', <Canvas />).render()}
|
||||
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
|
||||
<ZoomPlugin onZoom={onChangeTimeRange} />
|
||||
<ContextMenuPlugin />
|
||||
|
||||
{/* TODO: */}
|
||||
{/*<AnnotationsEditorPlugin />*/}
|
||||
</UPlotChart>
|
||||
);
|
||||
}}
|
||||
</VizLayout>
|
||||
);
|
||||
};
|
52
public/app/plugins/panel/graph3/LayoutBuilder.ts
Normal file
52
public/app/plugins/panel/graph3/LayoutBuilder.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface LayoutRendererComponentProps<T extends string> {
|
||||
slots: Partial<Record<T, React.ReactNode | null>>;
|
||||
refs: Record<T, (i: any) => void>;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type LayoutRendererComponent<T extends string> = React.ComponentType<LayoutRendererComponentProps<T>>;
|
||||
|
||||
// Fluent API for defining and rendering layout
|
||||
export class LayoutBuilder<T extends string> {
|
||||
private layout: Partial<Record<T, React.ReactNode | null>> = {};
|
||||
|
||||
constructor(
|
||||
private renderer: LayoutRendererComponent<T>,
|
||||
private refsMap: Record<T, (i: any) => void>,
|
||||
private width: number,
|
||||
private height: number
|
||||
) {}
|
||||
|
||||
getLayout() {
|
||||
return this.layout;
|
||||
}
|
||||
addSlot(id: T, node: React.ReactNode) {
|
||||
this.layout[id] = node;
|
||||
return this;
|
||||
}
|
||||
|
||||
clearSlot(id: T) {
|
||||
if (this.layout[id] && this.refsMap[id]) {
|
||||
delete this.layout[id];
|
||||
|
||||
this.refsMap[id](null);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.layout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.createElement(this.renderer, {
|
||||
slots: this.layout,
|
||||
refs: this.refsMap,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
});
|
||||
}
|
||||
}
|
5
public/app/plugins/panel/graph3/README.md
Normal file
5
public/app/plugins/panel/graph3/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Graph3 Panel - Native Plugin
|
||||
|
||||
This is a graph panel exprimenting with the charting library:
|
||||
https://github.com/leeoniya/uPlot
|
||||
|
221
public/app/plugins/panel/graph3/VizLayout.tsx
Normal file
221
public/app/plugins/panel/graph3/VizLayout.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { useMeasure } from './useMeasure';
|
||||
import { LayoutBuilder, LayoutRendererComponent } from './LayoutBuilder';
|
||||
import { CustomScrollbar } from '@grafana/ui';
|
||||
|
||||
type UseMeasureRect = Pick<DOMRectReadOnly, 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'>;
|
||||
|
||||
const RESET_DIMENSIONS: UseMeasureRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_VIZ_LAYOUT_STATE = {
|
||||
isReady: false,
|
||||
top: RESET_DIMENSIONS,
|
||||
bottom: RESET_DIMENSIONS,
|
||||
right: RESET_DIMENSIONS,
|
||||
left: RESET_DIMENSIONS,
|
||||
canvas: RESET_DIMENSIONS,
|
||||
};
|
||||
|
||||
export type VizLayoutSlots = 'top' | 'bottom' | 'left' | 'right' | 'canvas';
|
||||
|
||||
export interface VizLayoutState extends Record<VizLayoutSlots, UseMeasureRect> {
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
interface VizLayoutAPI {
|
||||
builder: LayoutBuilder<VizLayoutSlots>;
|
||||
getLayout: () => VizLayoutState;
|
||||
}
|
||||
|
||||
interface VizLayoutProps {
|
||||
width: number;
|
||||
height: number;
|
||||
children: (api: VizLayoutAPI) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph viz layout. Consists of 5 slots: top(T), bottom(B), left(L), right(R), canvas:
|
||||
*
|
||||
* +-----------------------------------------------+
|
||||
* | T |
|
||||
* ----|---------------------------------------|----
|
||||
* | | | |
|
||||
* | | | |
|
||||
* | L | CANVAS SLOT | R |
|
||||
* | | | |
|
||||
* | | | |
|
||||
* ----|---------------------------------------|----
|
||||
* | B |
|
||||
* +-----------------------------------------------+
|
||||
*
|
||||
*/
|
||||
const VizLayoutRenderer: LayoutRendererComponent<VizLayoutSlots> = ({ slots, refs, width, height }) => {
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
height: ${height}px;
|
||||
width: ${width}px;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
`}
|
||||
>
|
||||
{slots.top && (
|
||||
<div
|
||||
ref={refs.top}
|
||||
className={css`
|
||||
width: 100%;
|
||||
max-height: 35%;
|
||||
align-self: top;
|
||||
`}
|
||||
>
|
||||
<CustomScrollbar>{slots.top}</CustomScrollbar>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(slots.left || slots.right || slots.canvas) && (
|
||||
<div
|
||||
className={css`
|
||||
label: INNER;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
{slots.left && (
|
||||
<div
|
||||
ref={refs.left}
|
||||
className={css`
|
||||
max-height: 100%;
|
||||
`}
|
||||
>
|
||||
<CustomScrollbar>{slots.left}</CustomScrollbar>
|
||||
</div>
|
||||
)}
|
||||
{slots.canvas && <div>{slots.canvas}</div>}
|
||||
{slots.right && (
|
||||
<div
|
||||
ref={refs.right}
|
||||
className={css`
|
||||
max-height: 100%;
|
||||
`}
|
||||
>
|
||||
<CustomScrollbar>{slots.right}</CustomScrollbar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{slots.bottom && (
|
||||
<div
|
||||
ref={refs.bottom}
|
||||
className={css`
|
||||
width: 100%;
|
||||
max-height: 35%;
|
||||
`}
|
||||
>
|
||||
<CustomScrollbar>{slots.bottom}</CustomScrollbar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VizLayout: React.FC<VizLayoutProps> = ({ children, width, height }) => {
|
||||
/**
|
||||
* Layout slots refs & bboxes
|
||||
* Refs are passed down to the renderer component by layout builder
|
||||
* It's up to the renderer to assign refs to correct slots(which are underlying DOM elements)
|
||||
* */
|
||||
const [bottomSlotRef, bottomSlotBBox] = useMeasure();
|
||||
const [topSlotRef, topSlotBBox] = useMeasure();
|
||||
const [leftSlotRef, leftSlotBBox] = useMeasure();
|
||||
const [rightSlotRef, rightSlotBBox] = useMeasure();
|
||||
const [canvasSlotRef, canvasSlotBBox] = useMeasure();
|
||||
|
||||
// public fluent API exposed via render prop to build the layout
|
||||
const builder = useMemo(
|
||||
() =>
|
||||
new LayoutBuilder(
|
||||
VizLayoutRenderer,
|
||||
{
|
||||
top: topSlotRef,
|
||||
bottom: bottomSlotRef,
|
||||
left: leftSlotRef,
|
||||
right: rightSlotRef,
|
||||
canvas: canvasSlotRef,
|
||||
},
|
||||
width,
|
||||
height
|
||||
),
|
||||
[bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox, width, height]
|
||||
);
|
||||
|
||||
// memoized map of layout slot bboxes, used for exposing correct bboxes when the layout is ready
|
||||
const bboxMap = useMemo(
|
||||
() => ({
|
||||
top: topSlotBBox,
|
||||
bottom: bottomSlotBBox,
|
||||
left: leftSlotBBox,
|
||||
right: rightSlotBBox,
|
||||
canvas: canvasSlotBBox,
|
||||
}),
|
||||
[bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox]
|
||||
);
|
||||
|
||||
const [dimensions, setDimensions] = useState<VizLayoutState>(DEFAULT_VIZ_LAYOUT_STATE);
|
||||
|
||||
// when DOM settles we set the layout to be ready to get measurements downstream
|
||||
useLayoutEffect(() => {
|
||||
// layout is ready by now
|
||||
const currentLayout = builder.getLayout();
|
||||
|
||||
// map active layout slots to corresponding bboxes
|
||||
let nextDimensions: Partial<Record<VizLayoutSlots, UseMeasureRect>> = {};
|
||||
for (const key of Object.keys(currentLayout)) {
|
||||
nextDimensions[key as VizLayoutSlots] = bboxMap[key as VizLayoutSlots];
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
// first, reset all bboxes to defaults
|
||||
...DEFAULT_VIZ_LAYOUT_STATE,
|
||||
// set layout to ready
|
||||
isReady: true,
|
||||
// update state with active slot bboxes
|
||||
...nextDimensions,
|
||||
};
|
||||
|
||||
setDimensions(nextState);
|
||||
}, [bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox, width, height]);
|
||||
|
||||
// returns current state of the layout, bounding rects of all slots to be rendered
|
||||
const getLayout = useCallback(() => {
|
||||
return dimensions;
|
||||
}, [dimensions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
label: PanelVizLayout;
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
overflow: hidden;
|
||||
`}
|
||||
>
|
||||
{children({
|
||||
builder: builder,
|
||||
getLayout,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
1
public/app/plugins/panel/graph3/img/icn-graph-panel.svg
Normal file
1
public/app/plugins/panel/graph3/img/icn-graph-panel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 82.59 82.5"><defs><style>.cls-1{fill:#3865ab;}.cls-2{fill:#84aff1;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="21.17" x2="82.59" y2="21.17" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect class="cls-1" x="73.22" y="19.61" width="8" height="62.89" rx="1"/><rect class="cls-1" x="1.78" y="53.61" width="8" height="28.89" rx="1"/><path class="cls-2" d="M8.78,82.5h-6a1,1,0,0,1-1-1V71.61h8V81.5A1,1,0,0,1,8.78,82.5Z"/><path class="cls-2" d="M80.22,82.5h-6a1,1,0,0,1-1-1V46.61h8V81.5A1,1,0,0,1,80.22,82.5Z"/><rect class="cls-1" x="58.93" y="49.61" width="8" height="32.89" rx="1"/><path class="cls-2" d="M65.93,82.5h-6a1,1,0,0,1-1-1V64.61h8V81.5A1,1,0,0,1,65.93,82.5Z"/><rect class="cls-1" x="44.64" y="38.61" width="8" height="43.89" rx="1"/><path class="cls-2" d="M51.64,82.5h-6a1,1,0,0,1-1-1V75.61h8V81.5A1,1,0,0,1,51.64,82.5Z"/><rect class="cls-1" x="30.36" y="27.61" width="8" height="54.89" rx="1"/><path class="cls-2" d="M37.36,82.5h-6a1,1,0,0,1-1-1V42.61h8V81.5A1,1,0,0,1,37.36,82.5Z"/><rect class="cls-1" x="16.07" y="37.61" width="8" height="44.89" rx="1"/><path class="cls-2" d="M23.07,82.5h-6a1,1,0,0,1-1-1V55.61h8V81.5A1,1,0,0,1,23.07,82.5Z"/><path class="cls-3" d="M2,42.33a2,2,0,0,1-1.44-.61,2,2,0,0,1,0-2.83l26-25a2,2,0,0,1,2.2-.39L54.56,25,79.18.58A2,2,0,0,1,82,3.42L56.41,28.75a2,2,0,0,1-2.22.41L28.42,17.71l-25,24.06A2,2,0,0,1,2,42.33Z"/></g></g></svg>
|
After Width: | Height: | Size: 1.6 KiB |
215
public/app/plugins/panel/graph3/module.tsx
Normal file
215
public/app/plugins/panel/graph3/module.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
import {
|
||||
FieldColor,
|
||||
FieldColorMode,
|
||||
identityOverrideProcessor,
|
||||
PanelPlugin,
|
||||
standardEditorsRegistry,
|
||||
} from '@grafana/data';
|
||||
import { GraphCustomFieldConfig } from '@grafana/ui';
|
||||
import { GraphPanel } from './GraphPanel';
|
||||
import { Options } from './types';
|
||||
|
||||
export const plugin = new PanelPlugin<Options, GraphCustomFieldConfig>(GraphPanel)
|
||||
.useFieldConfig({
|
||||
useCustomConfig: builder => {
|
||||
builder
|
||||
// TODO: Until we fix standard color property let's do it the custom editor way
|
||||
.addCustomEditor<{}, FieldColor>({
|
||||
path: 'line.color',
|
||||
id: 'line.color',
|
||||
name: 'Series color',
|
||||
shouldApply: () => true,
|
||||
settings: {},
|
||||
defaultValue: { mode: FieldColorMode.Fixed },
|
||||
editor: standardEditorsRegistry.get('color').editor as any,
|
||||
override: standardEditorsRegistry.get('color').editor as any,
|
||||
process: identityOverrideProcessor,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'line.show',
|
||||
name: 'Show lines',
|
||||
description: '',
|
||||
defaultValue: true,
|
||||
})
|
||||
.addSelect({
|
||||
path: 'line.width',
|
||||
name: 'Line width',
|
||||
defaultValue: 1,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 1, label: '1 • thin' },
|
||||
{ value: 2, label: '2' },
|
||||
{ value: 3, label: '3' },
|
||||
{ value: 4, label: '4' },
|
||||
{ value: 5, label: '5' },
|
||||
{ value: 6, label: '6' },
|
||||
{ value: 7, label: '7' },
|
||||
{ value: 8, label: '8' },
|
||||
{ value: 9, label: '9' },
|
||||
{ value: 10, label: '10 • thick' },
|
||||
],
|
||||
},
|
||||
showIf: c => {
|
||||
console.log(c);
|
||||
return c.line.show;
|
||||
},
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'points.show',
|
||||
name: 'Show points',
|
||||
description: '',
|
||||
defaultValue: false,
|
||||
})
|
||||
.addSelect({
|
||||
path: 'points.radius',
|
||||
name: 'Point radius',
|
||||
defaultValue: 4,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 1, label: '1 • thin' },
|
||||
{ value: 2, label: '2' },
|
||||
{ value: 3, label: '3' },
|
||||
{ value: 4, label: '4' },
|
||||
{ value: 5, label: '5' },
|
||||
{ value: 6, label: '6' },
|
||||
{ value: 7, label: '7' },
|
||||
{ value: 8, label: '8' },
|
||||
{ value: 9, label: '9' },
|
||||
{ value: 10, label: '10 • thick' },
|
||||
],
|
||||
},
|
||||
showIf: c => c.points.show,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'bars.show',
|
||||
name: 'Show bars',
|
||||
description: '',
|
||||
defaultValue: false,
|
||||
})
|
||||
.addSelect({
|
||||
path: 'fill.alpha',
|
||||
name: 'Fill area opacity',
|
||||
defaultValue: 0.1,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 0, label: 'No Fill' },
|
||||
{ value: 0.1, label: '10% • transparent' },
|
||||
{ value: 0.2, label: '20%' },
|
||||
{ value: 0.3, label: '30%' },
|
||||
{ value: 0.4, label: '40% ' },
|
||||
{ value: 0.5, label: '50%' },
|
||||
{ value: 0.6, label: '60%' },
|
||||
{ value: 0.7, label: '70%' },
|
||||
{ value: 0.8, label: '80%' },
|
||||
{ value: 0.9, label: '90%' },
|
||||
{ value: 1, label: '100% • opaque' },
|
||||
],
|
||||
},
|
||||
})
|
||||
.addTextInput({
|
||||
path: 'axis.label',
|
||||
name: 'Axis Label',
|
||||
category: ['Axis'],
|
||||
defaultValue: '',
|
||||
settings: {
|
||||
placeholder: 'Optional text',
|
||||
},
|
||||
// no matter what the field type is
|
||||
shouldApply: () => true,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'axis.side',
|
||||
name: 'Y axis side',
|
||||
category: ['Axis'],
|
||||
defaultValue: 3,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 3, label: 'Left' },
|
||||
{ value: 1, label: 'Right' },
|
||||
],
|
||||
},
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'axis.width',
|
||||
name: 'Y axis width',
|
||||
category: ['Axis'],
|
||||
defaultValue: 60,
|
||||
settings: {
|
||||
placeholder: '60',
|
||||
},
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'axis.grid',
|
||||
name: 'Show axis grid',
|
||||
category: ['Axis'],
|
||||
description: '',
|
||||
defaultValue: true,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'nullValues',
|
||||
name: 'Display null values as',
|
||||
description: '',
|
||||
defaultValue: 'null',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 'null', label: 'null' },
|
||||
{ value: 'connected', label: 'Connected' },
|
||||
{ value: 'asZero', label: 'Zero' },
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.setPanelOptions(builder => {
|
||||
builder
|
||||
.addRadio({
|
||||
path: 'tooltipOptions.mode',
|
||||
name: 'Tooltip mode',
|
||||
description: '',
|
||||
defaultValue: 'single',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 'single', label: 'Single series' },
|
||||
{ value: 'multi', label: 'All series' },
|
||||
{ value: 'none', label: 'No tooltip' },
|
||||
],
|
||||
},
|
||||
})
|
||||
// .addBooleanSwitch({
|
||||
// path: 'graph.realTimeUpdates',
|
||||
// name: 'Real time updates',
|
||||
// description: 'continue to update the graph so the time axis matches the clock.',
|
||||
// defaultValue: false,
|
||||
// })
|
||||
.addBooleanSwitch({
|
||||
category: ['Legend'],
|
||||
path: 'legend.isVisible',
|
||||
name: 'Show legend',
|
||||
description: '',
|
||||
defaultValue: true,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
category: ['Legend'],
|
||||
path: 'legend.asTable',
|
||||
name: 'Display legend as table',
|
||||
description: '',
|
||||
defaultValue: false,
|
||||
showIf: c => c.legend.isVisible,
|
||||
})
|
||||
.addRadio({
|
||||
category: ['Legend'],
|
||||
path: 'legend.placement',
|
||||
name: 'Legend placement',
|
||||
description: '',
|
||||
defaultValue: 'bottom',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'top', label: 'Top' },
|
||||
{ value: 'bottom', label: 'Bottom' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
],
|
||||
},
|
||||
showIf: c => c.legend.isVisible,
|
||||
});
|
||||
});
|
17
public/app/plugins/panel/graph3/plugin.json
Normal file
17
public/app/plugins/panel/graph3/plugin.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"name": "uPlot graph",
|
||||
"id": "graph3",
|
||||
"state": "alpha",
|
||||
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/icn-graph-panel.svg",
|
||||
"large": "img/icn-graph-panel.svg"
|
||||
}
|
||||
}
|
||||
}
|
25
public/app/plugins/panel/graph3/types.ts
Normal file
25
public/app/plugins/panel/graph3/types.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { LegendOptions, GraphTooltipOptions } from '@grafana/ui';
|
||||
|
||||
export type NullValuesMode = 'null' | 'connected' | 'asZero';
|
||||
export type LegendPlacement = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
export interface GraphOptions {
|
||||
// Redraw as time passes
|
||||
realTimeUpdates?: boolean;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
graph: GraphOptions;
|
||||
legend: Omit<LegendOptions, 'placement'> &
|
||||
GraphLegendEditorLegendOptions & {
|
||||
placement: LegendPlacement;
|
||||
};
|
||||
tooltipOptions: GraphTooltipOptions;
|
||||
}
|
||||
|
||||
export interface GraphLegendEditorLegendOptions extends LegendOptions {
|
||||
stats?: string[];
|
||||
decimals?: number;
|
||||
sortBy?: string;
|
||||
sortDesc?: boolean;
|
||||
}
|
49
public/app/plugins/panel/graph3/useMeasure.tsx
Normal file
49
public/app/plugins/panel/graph3/useMeasure.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIsomorphicLayoutEffect } from 'react-use';
|
||||
|
||||
export type UseMeasureRect = Pick<
|
||||
DOMRectReadOnly,
|
||||
'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'
|
||||
>;
|
||||
export type UseMeasureRef<E extends HTMLElement = HTMLElement> = (element: E) => void;
|
||||
export type UseMeasureResult<E extends HTMLElement = HTMLElement> = [UseMeasureRef<E>, UseMeasureRect];
|
||||
|
||||
const defaultState: UseMeasureRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
};
|
||||
|
||||
export const useMeasure = <E extends HTMLElement = HTMLElement>(): UseMeasureResult<E> => {
|
||||
const [element, ref] = useState<E | null>(null);
|
||||
const [rect, setRect] = useState<UseMeasureRect>(defaultState);
|
||||
|
||||
const observer = useMemo(
|
||||
() =>
|
||||
new (window as any).ResizeObserver((entries: any) => {
|
||||
if (entries[0]) {
|
||||
const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect;
|
||||
setRect({ x, y, width, height, top, left, bottom, right });
|
||||
}
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (!element) {
|
||||
setRect(defaultState);
|
||||
return;
|
||||
}
|
||||
observer.observe(element);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [element]);
|
||||
|
||||
return [ref, rect];
|
||||
};
|
39
public/app/plugins/panel/graph3/utils.ts
Normal file
39
public/app/plugins/panel/graph3/utils.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { DataFrame, FieldType, getTimeField, sortDataFrame, transformDataFrame } from '@grafana/data';
|
||||
|
||||
// very time oriented for now
|
||||
export const alignAndSortDataFramesByFieldName = (data: DataFrame[], fieldName: string) => {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// uPlot data needs to be aligned on x-axis (ref. https://github.com/leeoniya/uPlot/issues/108)
|
||||
// For experimentation just assuming alignment on time field, needs to change
|
||||
const aligned = transformDataFrame(
|
||||
[
|
||||
{
|
||||
id: 'seriesToColumns',
|
||||
options: { byField: fieldName },
|
||||
},
|
||||
],
|
||||
dataFramesToPlot
|
||||
)[0];
|
||||
|
||||
// need to be more "clever", not time only in the future!
|
||||
return sortDataFrame(aligned, getTimeField(aligned).timeIndex!);
|
||||
};
|
@ -26315,6 +26315,11 @@ update-notifier@^2.5.0:
|
||||
semver-diff "^2.0.0"
|
||||
xdg-basedir "^3.0.0"
|
||||
|
||||
uplot@1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.1.2.tgz#ccdbe0987e7615d197e1dba77946a1655a823c31"
|
||||
integrity sha512-CpQmMdafoMRR+zRSpfpMXs5mKvqgYFakcCyt7nOfh+pPeZfbxNMcCq9JFeXJcKEaWjrR6JSIiEZ01A4iFHztTQ==
|
||||
|
||||
upper-case-first@^1.1.0, upper-case-first@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-1.1.2.tgz#5d79bedcff14419518fd2edb0a0507c9b6859115"
|
||||
|
Loading…
Reference in New Issue
Block a user