mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Graph NG: EventsCanvas & WIP exemplars support (#28071)
* Use annotations data observable * WIP exemplars * Refactor usePlotContext to use getters instead of properties * Use DataFrame in EventsCanvas instead of custom type * Minor tweaks
This commit is contained in:
parent
dc662025cd
commit
a3d1d9a9c5
@ -75,6 +75,7 @@ export * from './uPlot/geometries';
|
||||
export { usePlotConfigContext } from './uPlot/context';
|
||||
export { Canvas } from './uPlot/Canvas';
|
||||
export * from './uPlot/plugins';
|
||||
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
|
||||
export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context';
|
||||
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
|
@ -8,13 +8,9 @@ interface CanvasProps {
|
||||
|
||||
// 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} />;
|
||||
export const Canvas: React.FC<CanvasProps> = () => {
|
||||
const plotCtx = usePlotContext();
|
||||
return <div ref={plotCtx.canvasRef} />;
|
||||
};
|
||||
|
||||
Canvas.displayName = 'Canvas';
|
||||
|
@ -23,6 +23,13 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
|
||||
const prevConfig = usePrevious(currentConfig);
|
||||
|
||||
const getPlotInstance = useCallback(() => {
|
||||
if (!plotInstance) {
|
||||
throw new Error("Plot hasn't initialised yet");
|
||||
}
|
||||
return plotInstance;
|
||||
}, [plotInstance]);
|
||||
|
||||
// Main function initialising uPlot. If final config is not settled it will do nothing
|
||||
const initPlot = () => {
|
||||
if (!currentConfig || !canvasRef.current) {
|
||||
@ -81,8 +88,17 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
|
||||
// Memoize plot context
|
||||
const plotCtx = useMemo(() => {
|
||||
return buildPlotContext(registerPlugin, addSeries, addAxis, addScale, canvasRef, props.data, plotInstance);
|
||||
}, [registerPlugin, addSeries, addAxis, addScale, canvasRef, props.data, plotInstance]);
|
||||
return buildPlotContext(
|
||||
Boolean(plotInstance),
|
||||
canvasRef,
|
||||
props.data,
|
||||
registerPlugin,
|
||||
addSeries,
|
||||
addAxis,
|
||||
addScale,
|
||||
getPlotInstance
|
||||
);
|
||||
}, [plotInstance, canvasRef, props.data, registerPlugin, addSeries, addAxis, addScale, getPlotInstance]);
|
||||
|
||||
return (
|
||||
<PlotContext.Provider value={plotCtx}>
|
||||
|
@ -43,28 +43,29 @@ interface PlotPluginsContextType {
|
||||
}
|
||||
|
||||
interface PlotContextType extends PlotConfigContextType, PlotPluginsContextType {
|
||||
u?: uPlot;
|
||||
series?: uPlot.Series[];
|
||||
canvas?: PlotCanvasContextType;
|
||||
isPlotReady: boolean;
|
||||
getPlotInstance: () => uPlot;
|
||||
getSeries: () => uPlot.Series[];
|
||||
getCanvas: () => PlotCanvasContextType;
|
||||
canvasRef: any;
|
||||
data: DataFrame;
|
||||
}
|
||||
|
||||
export const PlotContext = React.createContext<PlotContextType | null>(null);
|
||||
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
|
||||
|
||||
// Exposes uPlot instance and bounding box of the entire canvas and plot area
|
||||
export const usePlotContext = (): PlotContextType | null => {
|
||||
return useContext<PlotContextType | null>(PlotContext);
|
||||
export const usePlotContext = (): PlotContextType => {
|
||||
return useContext<PlotContextType>(PlotContext);
|
||||
};
|
||||
|
||||
const throwWhenNoContext = (name: string) => {
|
||||
throw new Error(`${name} must be used within PlotContext`);
|
||||
throw new Error(`${name} must be used within PlotContext or PlotContext is not ready yet!`);
|
||||
};
|
||||
|
||||
// Exposes API for registering uPlot plugins
|
||||
export const usePlotPluginContext = (): PlotPluginsContextType => {
|
||||
const ctx = useContext(PlotContext);
|
||||
if (!ctx) {
|
||||
if (Object.keys(ctx).length === 0) {
|
||||
throwWhenNoContext('usePlotPluginContext');
|
||||
}
|
||||
return {
|
||||
@ -74,7 +75,8 @@ export const usePlotPluginContext = (): PlotPluginsContextType => {
|
||||
|
||||
// Exposes API for building uPlot config
|
||||
export const usePlotConfigContext = (): PlotConfigContextType => {
|
||||
const ctx = useContext(PlotContext);
|
||||
const ctx = usePlotContext();
|
||||
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotPluginContext');
|
||||
}
|
||||
@ -101,7 +103,7 @@ interface PlotDataAPI {
|
||||
}
|
||||
|
||||
export const usePlotData = (): PlotDataAPI => {
|
||||
const ctx = useContext(PlotContext);
|
||||
const ctx = usePlotContext();
|
||||
|
||||
const getField = useCallback(
|
||||
(idx: number) => {
|
||||
@ -149,7 +151,7 @@ export const usePlotData = (): PlotDataAPI => {
|
||||
}
|
||||
|
||||
return {
|
||||
data: ctx!.data,
|
||||
data: ctx.data,
|
||||
getField,
|
||||
getFieldValue,
|
||||
getFieldConfig,
|
||||
@ -158,45 +160,35 @@ export const usePlotData = (): PlotDataAPI => {
|
||||
};
|
||||
};
|
||||
|
||||
// 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 = (
|
||||
isPlotReady: boolean,
|
||||
canvasRef: any,
|
||||
data: DataFrame,
|
||||
registerPlugin: any,
|
||||
addSeries: any,
|
||||
addAxis: any,
|
||||
addScale: any,
|
||||
canvasRef: any,
|
||||
data: DataFrame,
|
||||
u?: uPlot
|
||||
): PlotContextType | null => {
|
||||
getPlotInstance: () => uPlot
|
||||
): PlotContextType => {
|
||||
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,
|
||||
isPlotReady,
|
||||
canvasRef,
|
||||
data,
|
||||
registerPlugin,
|
||||
addSeries,
|
||||
addAxis,
|
||||
addScale,
|
||||
canvasRef,
|
||||
data,
|
||||
getPlotInstance,
|
||||
getSeries: () => getPlotInstance().series,
|
||||
getCanvas: () => ({
|
||||
width: getPlotInstance().width,
|
||||
height: getPlotInstance().height,
|
||||
plot: {
|
||||
width: getPlotInstance().bbox.width / window.devicePixelRatio,
|
||||
height: getPlotInstance().bbox.height / window.devicePixelRatio,
|
||||
top: getPlotInstance().bbox.top / window.devicePixelRatio,
|
||||
left: getPlotInstance().bbox.left / window.devicePixelRatio,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -0,0 +1,51 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { DataFrame, DataFrameView } from '@grafana/data';
|
||||
import { usePlotContext } from '../context';
|
||||
import { Marker } from './Marker';
|
||||
import { XYCanvas } from './XYCanvas';
|
||||
import { useRefreshAfterGraphRendered } from '../hooks';
|
||||
|
||||
interface EventsCanvasProps<T> {
|
||||
id: string;
|
||||
events: DataFrame[];
|
||||
renderEventMarker: (event: T) => React.ReactNode;
|
||||
mapEventToXYCoords: (event: T) => { x: number; y: number } | undefined;
|
||||
}
|
||||
|
||||
export function EventsCanvas<T>({ id, events, renderEventMarker, mapEventToXYCoords }: EventsCanvasProps<T>) {
|
||||
const plotCtx = usePlotContext();
|
||||
const renderToken = useRefreshAfterGraphRendered(id);
|
||||
|
||||
const eventMarkers = useMemo(() => {
|
||||
const markers: React.ReactNode[] = [];
|
||||
|
||||
if (!plotCtx.isPlotReady || events.length === 0) {
|
||||
return markers;
|
||||
}
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const view = new DataFrameView<T>(events[i]);
|
||||
for (let j = 0; j < view.length; j++) {
|
||||
const event = view.get(j);
|
||||
|
||||
const coords = mapEventToXYCoords(event);
|
||||
if (!coords) {
|
||||
continue;
|
||||
}
|
||||
markers.push(
|
||||
<Marker {...coords} key={`${id}-marker-${i}-${j}`}>
|
||||
{renderEventMarker(event)}
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{markers}</>;
|
||||
}, [events, renderEventMarker, renderToken, plotCtx.isPlotReady]);
|
||||
|
||||
if (!plotCtx.isPlotReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <XYCanvas>{eventMarkers}</XYCanvas>;
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import { css } from 'emotion';
|
||||
import React from 'react';
|
||||
|
||||
interface MarkerProps {
|
||||
/** x position relative to plotting area bounding box*/
|
||||
x: number;
|
||||
/** y position relative to plotting area bounding box*/
|
||||
y: number;
|
||||
}
|
||||
|
||||
// An abstraction over a component rendered within a chart canvas.
|
||||
// Marker is rendered with DOM coords of the chart bounding box.
|
||||
export const Marker: React.FC<MarkerProps> = ({ x, y, children }) => {
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
top: ${y}px;
|
||||
left: ${x}px;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,30 @@
|
||||
import { usePlotContext } from '../context';
|
||||
import React from 'react';
|
||||
import { css } from 'emotion';
|
||||
|
||||
interface XYCanvasProps {}
|
||||
|
||||
/**
|
||||
* Renders absolutely positioned element on top of the uPlot's plotting area (axes are not included!).
|
||||
* Useful when you want to render some overlay with canvas-independent elements on top of the plot.
|
||||
*/
|
||||
export const XYCanvas: React.FC<XYCanvasProps> = ({ children }) => {
|
||||
const plotContext = usePlotContext();
|
||||
|
||||
if (!plotContext.isPlotReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
left: ${plotContext.getPlotInstance().bbox.left / window.devicePixelRatio}px;
|
||||
top: ${plotContext.getPlotInstance().bbox.top / window.devicePixelRatio}px;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -4,4 +4,7 @@ import { Point } from './Point';
|
||||
import { Axis } from './Axis';
|
||||
import { Scale } from './Scale';
|
||||
import { SeriesGeometry } from './SeriesGeometry';
|
||||
export { Area, Line, Point, SeriesGeometry, Axis, Scale };
|
||||
import { XYCanvas } from './XYCanvas';
|
||||
import { Marker } from './Marker';
|
||||
import { EventsCanvas } from './EventsCanvas';
|
||||
export { Area, Line, Point, SeriesGeometry, Axis, Scale, XYCanvas, Marker, EventsCanvas };
|
||||
|
@ -3,6 +3,7 @@ import { PlotPlugin } from './types';
|
||||
import { pluginLog } from './utils';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeZoneInfo, TimeZone } from '@grafana/data';
|
||||
import { usePlotPluginContext } from './context';
|
||||
|
||||
export const usePlotPlugins = () => {
|
||||
/**
|
||||
@ -246,3 +247,32 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone)
|
||||
currentConfig,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Forces re-render of a component when uPlots's draw hook is fired.
|
||||
* This hook is usefull in scenarios when you want to reposition XYCanvas elements when i.e. plot size changes
|
||||
* @param pluginId - id under which the plugin will be registered
|
||||
*/
|
||||
export const useRefreshAfterGraphRendered = (pluginId: string) => {
|
||||
const pluginsApi = usePlotPluginContext();
|
||||
const [renderToken, setRenderToken] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = pluginsApi.registerPlugin({
|
||||
id: pluginId,
|
||||
hooks: {
|
||||
// refresh events when uPlot draws
|
||||
draw: u => {
|
||||
setRenderToken(c => c + 1);
|
||||
return;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
unregister();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return renderToken;
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { PlotPluginProps } from '../types';
|
||||
import { usePlotCanvas, usePlotPluginContext } from '../context';
|
||||
import { usePlotContext, usePlotPluginContext } from '../context';
|
||||
import { pluginLog } from '../utils';
|
||||
|
||||
interface Selection {
|
||||
@ -33,10 +33,9 @@ interface SelectionPluginProps extends PlotPluginProps {
|
||||
export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDismiss, lazy, id, children }) => {
|
||||
const pluginId = `SelectionPlugin:${id}`;
|
||||
const pluginsApi = usePlotPluginContext();
|
||||
const canvas = usePlotCanvas();
|
||||
|
||||
const plotCtx = usePlotContext();
|
||||
const [selection, setSelection] = useState<Selection | null>(null);
|
||||
//
|
||||
|
||||
useEffect(() => {
|
||||
if (!lazy && selection) {
|
||||
pluginLog(pluginId, false, 'selected', selection);
|
||||
@ -77,7 +76,7 @@ export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDi
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!children || !canvas || !selection) {
|
||||
if (!plotCtx.isPlotReady || !children || !selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -25,7 +25,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
return (
|
||||
<CursorPlugin id={pluginId}>
|
||||
{({ focusedSeriesIdx, focusedPointIdx, coords }) => {
|
||||
if (!plotContext || !plotContext.series) {
|
||||
if (!plotContext.isPlotReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
series={[
|
||||
{
|
||||
// stroke is typed as CanvasRenderingContext2D['strokeStyle'] - we are using strings only for now
|
||||
color: plotContext.series![focusedSeriesIdx!].stroke as string,
|
||||
color: plotContext.getSeries()[focusedSeriesIdx!].stroke as string,
|
||||
label: getFieldDisplayName(field, data),
|
||||
value: fieldFmt(field.values.get(focusedPointIdx)).text,
|
||||
},
|
||||
@ -70,7 +70,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
...agg,
|
||||
{
|
||||
// stroke is typed as CanvasRenderingContext2D['strokeStyle'] - we are using strings only for now
|
||||
color: plotContext.series![i].stroke as string,
|
||||
color: plotContext.getSeries()[i].stroke as string,
|
||||
label: getFieldDisplayName(f, data),
|
||||
value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))),
|
||||
isActive: focusedSeriesIdx === i,
|
||||
|
@ -34,6 +34,7 @@ import { VizLayout } from './VizLayout';
|
||||
import { Axis } from '@grafana/ui/src/components/uPlot/geometries/Axis';
|
||||
import { timeFormatToTemplate } from '@grafana/ui/src/components/uPlot/utils';
|
||||
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
|
||||
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
|
||||
|
||||
interface GraphPanelProps extends PanelProps<Options> {}
|
||||
|
||||
@ -238,6 +239,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
<ZoomPlugin onZoom={onChangeTimeRange} />
|
||||
<ContextMenuPlugin />
|
||||
|
||||
{data.annotations && <ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} />}
|
||||
{data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />}
|
||||
{/* TODO: */}
|
||||
{/*<AnnotationsEditorPlugin />*/}
|
||||
|
@ -1,15 +1,15 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { AnnotationEvent, GrafanaTheme } from '@grafana/data';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { HorizontalGroup, Portal, Tag, TooltipContainer, useStyles } from '@grafana/ui';
|
||||
import { css, cx } from 'emotion';
|
||||
import { css } from 'emotion';
|
||||
|
||||
interface AnnotationMarkerProps {
|
||||
formatTime: (value: number) => string;
|
||||
annotationEvent: AnnotationEvent;
|
||||
x: number;
|
||||
time: string;
|
||||
text: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ annotationEvent, x, formatTime }) => {
|
||||
export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ time, text, tags }) => {
|
||||
const styles = useStyles(getAnnotationMarkerStyles);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const markerRef = useRef<HTMLDivElement>(null);
|
||||
@ -47,14 +47,14 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ annotationEv
|
||||
>
|
||||
<div ref={annotationPopoverRef} className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{annotationEvent.title}</span>
|
||||
{annotationEvent.time && <span className={styles.time}>{formatTime(annotationEvent.time)}</span>}
|
||||
{/*<span className={styles.title}>{annotationEvent.title}</span>*/}
|
||||
{time && <span className={styles.time}>{time}</span>}
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{annotationEvent.text && <div dangerouslySetInnerHTML={{ __html: annotationEvent.text }} />}
|
||||
{text && <div dangerouslySetInnerHTML={{ __html: text }} />}
|
||||
<>
|
||||
<HorizontalGroup spacing="xs" wrap>
|
||||
{annotationEvent.tags?.map((t, i) => (
|
||||
{tags?.map((t, i) => (
|
||||
<Tag name={t} key={`${t}-${i}`} />
|
||||
))}
|
||||
</HorizontalGroup>
|
||||
@ -63,21 +63,11 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ annotationEv
|
||||
</div>
|
||||
</TooltipContainer>
|
||||
);
|
||||
}, [annotationEvent]);
|
||||
}, [time, tags, text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={markerRef}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={cx(
|
||||
styles.markerWrapper,
|
||||
css`
|
||||
left: ${x - 8}px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div ref={markerRef} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className={styles.markerWrapper}>
|
||||
<div className={styles.marker} />
|
||||
</div>
|
||||
{isOpen && <Portal>{renderMarker()}</Portal>}
|
||||
@ -93,8 +83,6 @@ const getAnnotationMarkerStyles = (theme: GrafanaTheme) => {
|
||||
return {
|
||||
markerWrapper: css`
|
||||
padding: 0 4px 4px 4px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
`,
|
||||
marker: css`
|
||||
width: 0;
|
||||
|
@ -1,23 +1,26 @@
|
||||
import { AnnotationEvent, DataFrame, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
|
||||
import { usePlotContext, usePlotPluginContext, useTheme } from '@grafana/ui';
|
||||
import { getAnnotationsFromData } from 'app/features/annotations/standardAnnotationSupport';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
|
||||
import { EventsCanvas, usePlotContext, usePlotPluginContext, useTheme } from '@grafana/ui';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { AnnotationMarker } from './AnnotationMarker';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
interface AnnotationsPluginProps {
|
||||
annotations: DataFrame[];
|
||||
timeZone: TimeZone;
|
||||
}
|
||||
|
||||
interface AnnotationsDataFrameViewDTO {
|
||||
time: number;
|
||||
text: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone }) => {
|
||||
const pluginId = 'AnnotationsPlugin';
|
||||
const plotCtx = usePlotContext();
|
||||
const pluginsApi = usePlotPluginContext();
|
||||
const plotContext = usePlotContext();
|
||||
const annotationsRef = useRef<AnnotationEvent[]>();
|
||||
const [renderCounter, setRenderCounter] = useState(0);
|
||||
|
||||
const theme = useTheme();
|
||||
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
|
||||
|
||||
const timeFormatter = useCallback(
|
||||
(value: number) => {
|
||||
@ -29,54 +32,17 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
|
||||
[timeZone]
|
||||
);
|
||||
|
||||
const annotationEventsStream = useMemo(() => getAnnotationsFromData(annotations), [annotations]);
|
||||
const annotationsData = useObservable<AnnotationEvent[]>(annotationEventsStream);
|
||||
const annotationMarkers = useMemo(() => {
|
||||
if (!plotContext || !plotContext?.u) {
|
||||
return null;
|
||||
}
|
||||
const markers: AnnotationEvent[] = [];
|
||||
|
||||
if (!annotationsData) {
|
||||
return markers;
|
||||
}
|
||||
|
||||
for (let i = 0; i < annotationsData.length; i++) {
|
||||
const annotation = annotationsData[i];
|
||||
if (!annotation.time) {
|
||||
continue;
|
||||
}
|
||||
const xpos = plotContext.u.valToPos(annotation.time / 1000, 'x');
|
||||
markers.push(
|
||||
<AnnotationMarker
|
||||
x={xpos}
|
||||
key={`${annotation.time}-${i}`}
|
||||
formatTime={timeFormatter}
|
||||
annotationEvent={annotation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
left: ${plotContext.u.bbox.left / window.devicePixelRatio}px;
|
||||
top: ${plotContext.u.bbox.top / window.devicePixelRatio +
|
||||
plotContext.u.bbox.height / window.devicePixelRatio}px;
|
||||
width: ${plotContext.u.bbox.width / window.devicePixelRatio}px;
|
||||
height: 14px;
|
||||
`}
|
||||
>
|
||||
{markers}
|
||||
</div>
|
||||
);
|
||||
}, [annotationsData, timeFormatter, plotContext, renderCounter]);
|
||||
|
||||
// For uPlot plugin to have access to lates annotation data we need to update the data ref
|
||||
useEffect(() => {
|
||||
annotationsRef.current = annotationsData;
|
||||
}, [annotationsData]);
|
||||
if (plotCtx.isPlotReady && annotations.length > 0) {
|
||||
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
|
||||
|
||||
for (const frame of annotations) {
|
||||
views.push(new DataFrameView(frame));
|
||||
}
|
||||
|
||||
annotationsRef.current = views;
|
||||
}
|
||||
}, [plotCtx.isPlotReady, annotations]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = pluginsApi.registerPlugin({
|
||||
@ -91,26 +57,31 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
|
||||
if (!annotationsRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ctx = u.ctx;
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < annotationsRef.current.length; i++) {
|
||||
const annotation = annotationsRef.current[i];
|
||||
if (!annotation.time) {
|
||||
continue;
|
||||
const annotationsView = annotationsRef.current[i];
|
||||
for (let j = 0; j < annotationsView.length; j++) {
|
||||
const annotation = annotationsView.get(j);
|
||||
|
||||
if (!annotation.time) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const xpos = u.valToPos(annotation.time / 1000, 'x', true);
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = theme.palette.red;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.moveTo(xpos, u.bbox.top);
|
||||
ctx.lineTo(xpos, u.bbox.top + u.bbox.height);
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
}
|
||||
const xpos = u.valToPos(annotation.time / 1000, 'x', true);
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = theme.palette.red;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.moveTo(xpos, u.bbox.top);
|
||||
ctx.lineTo(xpos, u.bbox.top + u.bbox.height);
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
}
|
||||
setRenderCounter(c => c + 1);
|
||||
return;
|
||||
},
|
||||
},
|
||||
@ -121,9 +92,33 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!plotContext || !plotContext.u || !plotContext.canvas) {
|
||||
return null;
|
||||
}
|
||||
const mapAnnotationToXYCoords = useCallback(
|
||||
(annotation: AnnotationsDataFrameViewDTO) => {
|
||||
if (!annotation.time) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return <div>{annotationMarkers}</div>;
|
||||
return {
|
||||
x: plotCtx.getPlotInstance().valToPos(annotation.time / 1000, 'x'),
|
||||
y: plotCtx.getPlotInstance().bbox.height / window.devicePixelRatio + 4,
|
||||
};
|
||||
},
|
||||
[plotCtx.getPlotInstance]
|
||||
);
|
||||
|
||||
const renderMarker = useCallback(
|
||||
(annotation: AnnotationsDataFrameViewDTO) => {
|
||||
return <AnnotationMarker time={timeFormatter(annotation.time)} text={annotation.text} tags={annotation.tags} />;
|
||||
},
|
||||
[timeFormatter]
|
||||
);
|
||||
|
||||
return (
|
||||
<EventsCanvas<AnnotationsDataFrameViewDTO>
|
||||
id="annotations"
|
||||
events={annotations}
|
||||
renderEventMarker={renderMarker}
|
||||
mapEventToXYCoords={mapAnnotationToXYCoords}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
141
public/app/plugins/panel/graph3/plugins/ExemplarMarker.tsx
Normal file
141
public/app/plugins/panel/graph3/plugins/ExemplarMarker.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { HorizontalGroup, Portal, Tag, TooltipContainer, useStyles } from '@grafana/ui';
|
||||
import { css, cx } from 'emotion';
|
||||
|
||||
interface ExemplarMarkerProps {
|
||||
time: string;
|
||||
text: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ time, text, tags }) => {
|
||||
const styles = useStyles(getExemplarMarkerStyles);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const markerRef = useRef<HTMLDivElement>(null);
|
||||
const annotationPopoverRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRenderTimeout = useRef<NodeJS.Timer>();
|
||||
|
||||
const onMouseEnter = useCallback(() => {
|
||||
if (popoverRenderTimeout.current) {
|
||||
clearTimeout(popoverRenderTimeout.current);
|
||||
}
|
||||
setIsOpen(true);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
popoverRenderTimeout.current = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 100);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const renderMarker = useCallback(() => {
|
||||
if (!markerRef?.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const el = markerRef.current;
|
||||
const elBBox = el.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
<TooltipContainer
|
||||
position={{ x: elBBox.left, y: elBBox.top + elBBox.height }}
|
||||
offset={{ x: 0, y: 0 }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={styles.tooltip}
|
||||
>
|
||||
<div ref={annotationPopoverRef} className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
{/*<span className={styles.title}>{exemplar.title}</span>*/}
|
||||
{time && <span className={styles.time}>{time}</span>}
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{text && <div dangerouslySetInnerHTML={{ __html: text }} />}
|
||||
<>
|
||||
<HorizontalGroup spacing="xs" wrap>
|
||||
{tags?.map((t, i) => (
|
||||
<Tag name={t} key={`${t}-${i}`} />
|
||||
))}
|
||||
</HorizontalGroup>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContainer>
|
||||
);
|
||||
}, [time, tags, text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={markerRef} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className={cx(styles.markerWrapper)}>
|
||||
<svg viewBox="0 0 599 599" width="8" height="8">
|
||||
<path id="black_diamond" fill="#000" d="M 300,575 L 575,300 L 300,25 L 25,300 L 300,575 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && <Portal>{renderMarker()}</Portal>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
|
||||
const bg = theme.isDark ? theme.palette.dark2 : theme.palette.white;
|
||||
const headerBg = theme.isDark ? theme.palette.dark9 : theme.palette.gray5;
|
||||
const shadowColor = theme.isDark ? theme.palette.black : theme.palette.white;
|
||||
|
||||
return {
|
||||
markerWrapper: css`
|
||||
padding: 0 4px 4px 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
box-sizing: content-box;
|
||||
> svg {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
marker: css`
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 4px solid ${theme.palette.red};
|
||||
pointer-events: none;
|
||||
`,
|
||||
wrapper: css`
|
||||
background: ${bg};
|
||||
border: 1px solid ${headerBg};
|
||||
border-radius: ${theme.border.radius.md};
|
||||
max-width: 400px;
|
||||
box-shadow: 0 0 20px ${shadowColor};
|
||||
`,
|
||||
tooltip: css`
|
||||
background: none;
|
||||
padding: 0;
|
||||
`,
|
||||
header: css`
|
||||
background: ${headerBg};
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
`,
|
||||
title: css`
|
||||
font-weight: ${theme.typography.weight.semibold};
|
||||
padding-right: ${theme.spacing.md};
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
time: css`
|
||||
color: ${theme.colors.textWeak};
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
`,
|
||||
body: css`
|
||||
padding: ${theme.spacing.sm};
|
||||
font-weight: ${theme.typography.weight.semibold};
|
||||
`,
|
||||
};
|
||||
};
|
96
public/app/plugins/panel/graph3/plugins/ExemplarsPlugin.tsx
Normal file
96
public/app/plugins/panel/graph3/plugins/ExemplarsPlugin.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ArrayVector,
|
||||
DataFrame,
|
||||
dateTimeFormat,
|
||||
FieldType,
|
||||
MutableDataFrame,
|
||||
systemDateFormats,
|
||||
TimeZone,
|
||||
} from '@grafana/data';
|
||||
import { EventsCanvas, usePlotContext } from '@grafana/ui';
|
||||
import { ExemplarMarker } from './ExemplarMarker';
|
||||
|
||||
interface ExemplarsPluginProps {
|
||||
exemplars: DataFrame[];
|
||||
timeZone: TimeZone;
|
||||
}
|
||||
|
||||
// Type representing exemplars data frame fields
|
||||
interface ExemplarsDataFrameViewDTO {
|
||||
time: number;
|
||||
y: number;
|
||||
text: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, timeZone }) => {
|
||||
const plotCtx = usePlotContext();
|
||||
|
||||
// TEMPORARY MOCK
|
||||
const [exemplarsMock, setExemplarsMock] = useState<DataFrame[]>([]);
|
||||
|
||||
const timeFormatter = useCallback(
|
||||
(value: number) => {
|
||||
return dateTimeFormat(value, {
|
||||
format: systemDateFormats.fullDate,
|
||||
timeZone,
|
||||
});
|
||||
},
|
||||
[timeZone]
|
||||
);
|
||||
|
||||
// THIS EVENT ONLY MOCKS EXEMPLAR Y VALUE!!!! TO BE REMOVED WHEN WE GET CORRECT EXEMPLARS SHAPE VIA PROPS
|
||||
useEffect(() => {
|
||||
if (plotCtx.isPlotReady && exemplars.length) {
|
||||
const mocks: DataFrame[] = [];
|
||||
|
||||
for (const frame of exemplars) {
|
||||
const mock = new MutableDataFrame(frame);
|
||||
mock.addField({
|
||||
name: 'y',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector(
|
||||
Array(frame.length)
|
||||
.fill(0)
|
||||
.map(() => Math.random())
|
||||
),
|
||||
});
|
||||
mocks.push(mock);
|
||||
}
|
||||
|
||||
setExemplarsMock(mocks);
|
||||
}
|
||||
}, [plotCtx.isPlotReady, exemplars]);
|
||||
|
||||
const mapExemplarToXYCoords = useCallback(
|
||||
(exemplar: ExemplarsDataFrameViewDTO) => {
|
||||
if (!exemplar.time) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
x: plotCtx.getPlotInstance().valToPos(exemplar.time / 1000, 'x'),
|
||||
// exemplar.y is a temporary mock for an examplar. This Needs to be calculated according to examplar scale!
|
||||
y: Math.floor((exemplar.y * plotCtx.getPlotInstance().bbox.height) / window.devicePixelRatio),
|
||||
};
|
||||
},
|
||||
[plotCtx.getPlotInstance]
|
||||
);
|
||||
|
||||
const renderMarker = useCallback(
|
||||
(exemplar: ExemplarsDataFrameViewDTO) => {
|
||||
return <ExemplarMarker time={timeFormatter(exemplar.time)} text={exemplar.text} tags={exemplar.tags} />;
|
||||
},
|
||||
[timeFormatter]
|
||||
);
|
||||
|
||||
return (
|
||||
<EventsCanvas<ExemplarsDataFrameViewDTO>
|
||||
id="exemplars"
|
||||
events={exemplarsMock}
|
||||
renderEventMarker={renderMarker}
|
||||
mapEventToXYCoords={mapExemplarToXYCoords}
|
||||
/>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user