Commit a3d1d9a9 by Dominik Prokop Committed by GitHub

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
parent dc662025
...@@ -75,6 +75,7 @@ export * from './uPlot/geometries'; ...@@ -75,6 +75,7 @@ export * from './uPlot/geometries';
export { usePlotConfigContext } from './uPlot/context'; export { usePlotConfigContext } from './uPlot/context';
export { Canvas } from './uPlot/Canvas'; export { Canvas } from './uPlot/Canvas';
export * from './uPlot/plugins'; export * from './uPlot/plugins';
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context'; export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context';
export { Gauge } from './Gauge/Gauge'; export { Gauge } from './Gauge/Gauge';
......
...@@ -8,13 +8,9 @@ interface CanvasProps { ...@@ -8,13 +8,9 @@ interface CanvasProps {
// Ref element to render the uPlot canvas to // Ref element to render the uPlot canvas to
// This is a required child of Plot component! // This is a required child of Plot component!
export const Canvas: React.FC<CanvasProps> = ({ width, height }) => { export const Canvas: React.FC<CanvasProps> = () => {
const plot = usePlotContext(); const plotCtx = usePlotContext();
if (!plot) { return <div ref={plotCtx.canvasRef} />;
return null;
}
return <div ref={plot.canvasRef} />;
}; };
Canvas.displayName = 'Canvas'; Canvas.displayName = 'Canvas';
...@@ -23,6 +23,13 @@ export const UPlotChart: React.FC<PlotProps> = props => { ...@@ -23,6 +23,13 @@ export const UPlotChart: React.FC<PlotProps> = props => {
const prevConfig = usePrevious(currentConfig); 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 // Main function initialising uPlot. If final config is not settled it will do nothing
const initPlot = () => { const initPlot = () => {
if (!currentConfig || !canvasRef.current) { if (!currentConfig || !canvasRef.current) {
...@@ -81,8 +88,17 @@ export const UPlotChart: React.FC<PlotProps> = props => { ...@@ -81,8 +88,17 @@ export const UPlotChart: React.FC<PlotProps> = props => {
// Memoize plot context // Memoize plot context
const plotCtx = useMemo(() => { const plotCtx = useMemo(() => {
return buildPlotContext(registerPlugin, addSeries, addAxis, addScale, canvasRef, props.data, plotInstance); return buildPlotContext(
}, [registerPlugin, addSeries, addAxis, addScale, canvasRef, props.data, plotInstance]); Boolean(plotInstance),
canvasRef,
props.data,
registerPlugin,
addSeries,
addAxis,
addScale,
getPlotInstance
);
}, [plotInstance, canvasRef, props.data, registerPlugin, addSeries, addAxis, addScale, getPlotInstance]);
return ( return (
<PlotContext.Provider value={plotCtx}> <PlotContext.Provider value={plotCtx}>
......
...@@ -43,28 +43,29 @@ interface PlotPluginsContextType { ...@@ -43,28 +43,29 @@ interface PlotPluginsContextType {
} }
interface PlotContextType extends PlotConfigContextType, PlotPluginsContextType { interface PlotContextType extends PlotConfigContextType, PlotPluginsContextType {
u?: uPlot; isPlotReady: boolean;
series?: uPlot.Series[]; getPlotInstance: () => uPlot;
canvas?: PlotCanvasContextType; getSeries: () => uPlot.Series[];
getCanvas: () => PlotCanvasContextType;
canvasRef: any; canvasRef: any;
data: DataFrame; 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 // Exposes uPlot instance and bounding box of the entire canvas and plot area
export const usePlotContext = (): PlotContextType | null => { export const usePlotContext = (): PlotContextType => {
return useContext<PlotContextType | null>(PlotContext); return useContext<PlotContextType>(PlotContext);
}; };
const throwWhenNoContext = (name: string) => { 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 // Exposes API for registering uPlot plugins
export const usePlotPluginContext = (): PlotPluginsContextType => { export const usePlotPluginContext = (): PlotPluginsContextType => {
const ctx = useContext(PlotContext); const ctx = useContext(PlotContext);
if (!ctx) { if (Object.keys(ctx).length === 0) {
throwWhenNoContext('usePlotPluginContext'); throwWhenNoContext('usePlotPluginContext');
} }
return { return {
...@@ -74,7 +75,8 @@ export const usePlotPluginContext = (): PlotPluginsContextType => { ...@@ -74,7 +75,8 @@ export const usePlotPluginContext = (): PlotPluginsContextType => {
// Exposes API for building uPlot config // Exposes API for building uPlot config
export const usePlotConfigContext = (): PlotConfigContextType => { export const usePlotConfigContext = (): PlotConfigContextType => {
const ctx = useContext(PlotContext); const ctx = usePlotContext();
if (!ctx) { if (!ctx) {
throwWhenNoContext('usePlotPluginContext'); throwWhenNoContext('usePlotPluginContext');
} }
...@@ -101,7 +103,7 @@ interface PlotDataAPI { ...@@ -101,7 +103,7 @@ interface PlotDataAPI {
} }
export const usePlotData = (): PlotDataAPI => { export const usePlotData = (): PlotDataAPI => {
const ctx = useContext(PlotContext); const ctx = usePlotContext();
const getField = useCallback( const getField = useCallback(
(idx: number) => { (idx: number) => {
...@@ -149,7 +151,7 @@ export const usePlotData = (): PlotDataAPI => { ...@@ -149,7 +151,7 @@ export const usePlotData = (): PlotDataAPI => {
} }
return { return {
data: ctx!.data, data: ctx.data,
getField, getField,
getFieldValue, getFieldValue,
getFieldConfig, getFieldConfig,
...@@ -158,45 +160,35 @@ export const usePlotData = (): PlotDataAPI => { ...@@ -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 = ( export const buildPlotContext = (
isPlotReady: boolean,
canvasRef: any,
data: DataFrame,
registerPlugin: any, registerPlugin: any,
addSeries: any, addSeries: any,
addAxis: any, addAxis: any,
addScale: any, addScale: any,
canvasRef: any, getPlotInstance: () => uPlot
data: DataFrame, ): PlotContextType => {
u?: uPlot
): PlotContextType | null => {
return { return {
u, isPlotReady,
series: u?.series, canvasRef,
canvas: u data,
? {
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, registerPlugin,
addSeries, addSeries,
addAxis, addAxis,
addScale, addScale,
canvasRef, getPlotInstance,
data, 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,
},
}),
}; };
}; };
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>;
}
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>
);
};
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'; ...@@ -4,4 +4,7 @@ import { Point } from './Point';
import { Axis } from './Axis'; import { Axis } from './Axis';
import { Scale } from './Scale'; import { Scale } from './Scale';
import { SeriesGeometry } from './SeriesGeometry'; 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'; ...@@ -3,6 +3,7 @@ import { PlotPlugin } from './types';
import { pluginLog } from './utils'; import { pluginLog } from './utils';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { getTimeZoneInfo, TimeZone } from '@grafana/data'; import { getTimeZoneInfo, TimeZone } from '@grafana/data';
import { usePlotPluginContext } from './context';
export const usePlotPlugins = () => { export const usePlotPlugins = () => {
/** /**
...@@ -246,3 +247,32 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) ...@@ -246,3 +247,32 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone)
currentConfig, 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;
};
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { PlotPluginProps } from '../types'; import { PlotPluginProps } from '../types';
import { usePlotCanvas, usePlotPluginContext } from '../context'; import { usePlotContext, usePlotPluginContext } from '../context';
import { pluginLog } from '../utils'; import { pluginLog } from '../utils';
interface Selection { interface Selection {
...@@ -33,10 +33,9 @@ interface SelectionPluginProps extends PlotPluginProps { ...@@ -33,10 +33,9 @@ interface SelectionPluginProps extends PlotPluginProps {
export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDismiss, lazy, id, children }) => { export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDismiss, lazy, id, children }) => {
const pluginId = `SelectionPlugin:${id}`; const pluginId = `SelectionPlugin:${id}`;
const pluginsApi = usePlotPluginContext(); const pluginsApi = usePlotPluginContext();
const canvas = usePlotCanvas(); const plotCtx = usePlotContext();
const [selection, setSelection] = useState<Selection | null>(null); const [selection, setSelection] = useState<Selection | null>(null);
//
useEffect(() => { useEffect(() => {
if (!lazy && selection) { if (!lazy && selection) {
pluginLog(pluginId, false, 'selected', selection); pluginLog(pluginId, false, 'selected', selection);
...@@ -77,7 +76,7 @@ export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDi ...@@ -77,7 +76,7 @@ export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDi
}; };
}, []); }, []);
if (!children || !canvas || !selection) { if (!plotCtx.isPlotReady || !children || !selection) {
return null; return null;
} }
......
...@@ -25,7 +25,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t ...@@ -25,7 +25,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
return ( return (
<CursorPlugin id={pluginId}> <CursorPlugin id={pluginId}>
{({ focusedSeriesIdx, focusedPointIdx, coords }) => { {({ focusedSeriesIdx, focusedPointIdx, coords }) => {
if (!plotContext || !plotContext.series) { if (!plotContext.isPlotReady) {
return null; return null;
} }
...@@ -46,7 +46,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t ...@@ -46,7 +46,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
series={[ series={[
{ {
// stroke is typed as CanvasRenderingContext2D['strokeStyle'] - we are using strings only for now // 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), label: getFieldDisplayName(field, data),
value: fieldFmt(field.values.get(focusedPointIdx)).text, value: fieldFmt(field.values.get(focusedPointIdx)).text,
}, },
...@@ -70,7 +70,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t ...@@ -70,7 +70,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
...agg, ...agg,
{ {
// stroke is typed as CanvasRenderingContext2D['strokeStyle'] - we are using strings only for now // 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), label: getFieldDisplayName(f, data),
value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))), value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))),
isActive: focusedSeriesIdx === i, isActive: focusedSeriesIdx === i,
......
...@@ -34,6 +34,7 @@ import { VizLayout } from './VizLayout'; ...@@ -34,6 +34,7 @@ import { VizLayout } from './VizLayout';
import { Axis } from '@grafana/ui/src/components/uPlot/geometries/Axis'; import { Axis } from '@grafana/ui/src/components/uPlot/geometries/Axis';
import { timeFormatToTemplate } from '@grafana/ui/src/components/uPlot/utils'; import { timeFormatToTemplate } from '@grafana/ui/src/components/uPlot/utils';
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin'; import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
interface GraphPanelProps extends PanelProps<Options> {} interface GraphPanelProps extends PanelProps<Options> {}
...@@ -238,6 +239,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({ ...@@ -238,6 +239,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
<ZoomPlugin onZoom={onChangeTimeRange} /> <ZoomPlugin onZoom={onChangeTimeRange} />
<ContextMenuPlugin /> <ContextMenuPlugin />
{data.annotations && <ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} />}
{data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />} {data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />}
{/* TODO: */} {/* TODO: */}
{/*<AnnotationsEditorPlugin />*/} {/*<AnnotationsEditorPlugin />*/}
......
import React, { useCallback, useRef, useState } from 'react'; 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 { HorizontalGroup, Portal, Tag, TooltipContainer, useStyles } from '@grafana/ui';
import { css, cx } from 'emotion'; import { css } from 'emotion';
interface AnnotationMarkerProps { interface AnnotationMarkerProps {
formatTime: (value: number) => string; time: string;
annotationEvent: AnnotationEvent; text: string;
x: number; 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 styles = useStyles(getAnnotationMarkerStyles);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const markerRef = useRef<HTMLDivElement>(null); const markerRef = useRef<HTMLDivElement>(null);
...@@ -47,14 +47,14 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ annotationEv ...@@ -47,14 +47,14 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ annotationEv
> >
<div ref={annotationPopoverRef} className={styles.wrapper}> <div ref={annotationPopoverRef} className={styles.wrapper}>
<div className={styles.header}> <div className={styles.header}>
<span className={styles.title}>{annotationEvent.title}</span> {/*<span className={styles.title}>{annotationEvent.title}</span>*/}
{annotationEvent.time && <span className={styles.time}>{formatTime(annotationEvent.time)}</span>} {time && <span className={styles.time}>{time}</span>}
</div> </div>
<div className={styles.body}> <div className={styles.body}>
{annotationEvent.text && <div dangerouslySetInnerHTML={{ __html: annotationEvent.text }} />} {text && <div dangerouslySetInnerHTML={{ __html: text }} />}
<> <>
<HorizontalGroup spacing="xs" wrap> <HorizontalGroup spacing="xs" wrap>
{annotationEvent.tags?.map((t, i) => ( {tags?.map((t, i) => (
<Tag name={t} key={`${t}-${i}`} /> <Tag name={t} key={`${t}-${i}`} />
))} ))}
</HorizontalGroup> </HorizontalGroup>
...@@ -63,21 +63,11 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ annotationEv ...@@ -63,21 +63,11 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ annotationEv
</div> </div>
</TooltipContainer> </TooltipContainer>
); );
}, [annotationEvent]); }, [time, tags, text]);
return ( return (
<> <>
<div <div ref={markerRef} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className={styles.markerWrapper}>
ref={markerRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={cx(
styles.markerWrapper,
css`
left: ${x - 8}px;
`
)}
>
<div className={styles.marker} /> <div className={styles.marker} />
</div> </div>
{isOpen && <Portal>{renderMarker()}</Portal>} {isOpen && <Portal>{renderMarker()}</Portal>}
...@@ -93,8 +83,6 @@ const getAnnotationMarkerStyles = (theme: GrafanaTheme) => { ...@@ -93,8 +83,6 @@ const getAnnotationMarkerStyles = (theme: GrafanaTheme) => {
return { return {
markerWrapper: css` markerWrapper: css`
padding: 0 4px 4px 4px; padding: 0 4px 4px 4px;
position: absolute;
top: 0;
`, `,
marker: css` marker: css`
width: 0; width: 0;
......
import { AnnotationEvent, DataFrame, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data'; import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
import { usePlotContext, usePlotPluginContext, useTheme } from '@grafana/ui'; import { EventsCanvas, usePlotContext, usePlotPluginContext, useTheme } from '@grafana/ui';
import { getAnnotationsFromData } from 'app/features/annotations/standardAnnotationSupport'; import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { css } from 'emotion';
import { AnnotationMarker } from './AnnotationMarker'; import { AnnotationMarker } from './AnnotationMarker';
import { useObservable } from 'react-use';
interface AnnotationsPluginProps { interface AnnotationsPluginProps {
annotations: DataFrame[]; annotations: DataFrame[];
timeZone: TimeZone; timeZone: TimeZone;
} }
interface AnnotationsDataFrameViewDTO {
time: number;
text: string;
tags: string[];
}
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone }) => { export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone }) => {
const pluginId = 'AnnotationsPlugin'; const pluginId = 'AnnotationsPlugin';
const plotCtx = usePlotContext();
const pluginsApi = usePlotPluginContext(); const pluginsApi = usePlotPluginContext();
const plotContext = usePlotContext();
const annotationsRef = useRef<AnnotationEvent[]>();
const [renderCounter, setRenderCounter] = useState(0);
const theme = useTheme(); const theme = useTheme();
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
const timeFormatter = useCallback( const timeFormatter = useCallback(
(value: number) => { (value: number) => {
...@@ -29,54 +32,17 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation ...@@ -29,54 +32,17 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
[timeZone] [timeZone]
); );
const annotationEventsStream = useMemo(() => getAnnotationsFromData(annotations), [annotations]); useEffect(() => {
const annotationsData = useObservable<AnnotationEvent[]>(annotationEventsStream); if (plotCtx.isPlotReady && annotations.length > 0) {
const annotationMarkers = useMemo(() => { const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
if (!plotContext || !plotContext?.u) {
return null;
}
const markers: AnnotationEvent[] = [];
if (!annotationsData) { for (const frame of annotations) {
return markers; views.push(new DataFrameView(frame));
} }
for (let i = 0; i < annotationsData.length; i++) { annotationsRef.current = views;
const annotation = annotationsData[i];
if (!annotation.time) {
continue;
} }
const xpos = plotContext.u.valToPos(annotation.time / 1000, 'x'); }, [plotCtx.isPlotReady, annotations]);
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]);
useEffect(() => { useEffect(() => {
const unregister = pluginsApi.registerPlugin({ const unregister = pluginsApi.registerPlugin({
...@@ -91,15 +57,20 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation ...@@ -91,15 +57,20 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
if (!annotationsRef.current) { if (!annotationsRef.current) {
return null; return null;
} }
const ctx = u.ctx; const ctx = u.ctx;
if (!ctx) { if (!ctx) {
return; return;
} }
for (let i = 0; i < annotationsRef.current.length; i++) { for (let i = 0; i < annotationsRef.current.length; i++) {
const annotation = annotationsRef.current[i]; const annotationsView = annotationsRef.current[i];
for (let j = 0; j < annotationsView.length; j++) {
const annotation = annotationsView.get(j);
if (!annotation.time) { if (!annotation.time) {
continue; continue;
} }
const xpos = u.valToPos(annotation.time / 1000, 'x', true); const xpos = u.valToPos(annotation.time / 1000, 'x', true);
ctx.beginPath(); ctx.beginPath();
ctx.lineWidth = 2; ctx.lineWidth = 2;
...@@ -110,7 +81,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation ...@@ -110,7 +81,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
ctx.stroke(); ctx.stroke();
ctx.closePath(); ctx.closePath();
} }
setRenderCounter(c => c + 1); }
return; return;
}, },
}, },
...@@ -121,9 +92,33 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation ...@@ -121,9 +92,33 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
}; };
}, []); }, []);
if (!plotContext || !plotContext.u || !plotContext.canvas) { const mapAnnotationToXYCoords = useCallback(
return null; (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}
/>
);
}; };
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};
`,
};
};
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}
/>
);
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment