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:
Dominik Prokop 2020-09-24 16:44:35 +02:00 committed by GitHub
parent 033feebbf9
commit 844dd9e8f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 4502 additions and 27 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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,

View File

@ -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",

View File

@ -93,7 +93,7 @@ describe('Chart Tooltip', () => {
// +--------------------++------+
// |origin|
// +------+
expect(styleAttribute).toContain('translate3d(890px, 590px, 0)');
expect(styleAttribute).toContain('translate3d(910px, 610px, 0)');
});
});
});

View File

@ -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:

View File

@ -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]);

View File

@ -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;

View File

@ -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;

View File

@ -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)} />

View File

@ -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';

View 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';

View 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>
);
};

View 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,
};
};

View 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,
};
};

View File

@ -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>
);
};

View 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>
</>
);
};

View File

@ -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>
);
};

View File

@ -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;
};

View File

@ -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}
/>
);
};

View File

@ -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,
});
};

View File

@ -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>
);
};

View File

@ -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 });
}}
/>
);
};

View File

@ -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';

View 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
};
}

View 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[];
}

View 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);
});
});

View 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);
};

View File

@ -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,
];
};

View File

@ -102,6 +102,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
};
onFieldConfigChange = (config: FieldConfigSource) => {
console.log(config);
const { panel } = this.props;
panel.updateFieldConfig({

View File

@ -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,

View 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>
);
};

View 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,
});
}
}

View File

@ -0,0 +1,5 @@
# Graph3 Panel - Native Plugin
This is a graph panel exprimenting with the charting library:
https://github.com/leeoniya/uPlot

View 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>
);
};

View 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

View 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,
});
});

View 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"
}
}
}

View 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;
}

View 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];
};

View 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!);
};

View File

@ -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"