diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 08873056a1b..70f39a1ab1b 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/components/uPlot/Canvas.tsx b/packages/grafana-ui/src/components/uPlot/Canvas.tsx index 085af85411b..e93be28c067 100644 --- a/packages/grafana-ui/src/components/uPlot/Canvas.tsx +++ b/packages/grafana-ui/src/components/uPlot/Canvas.tsx @@ -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 = ({ width, height }) => { - const plot = usePlotContext(); - if (!plot) { - return null; - } - - return
; +export const Canvas: React.FC = () => { + const plotCtx = usePlotContext(); + return
; }; Canvas.displayName = 'Canvas'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index 6a15dba7bbd..6121b9e2c13 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -23,6 +23,13 @@ export const UPlotChart: React.FC = 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 = 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 ( diff --git a/packages/grafana-ui/src/components/uPlot/context.ts b/packages/grafana-ui/src/components/uPlot/context.ts index 0451a48dbfd..a740aeaff34 100644 --- a/packages/grafana-ui/src/components/uPlot/context.ts +++ b/packages/grafana-ui/src/components/uPlot/context.ts @@ -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(null); +export const PlotContext = React.createContext({} as PlotContextType); // Exposes uPlot instance and bounding box of the entire canvas and plot area -export const usePlotContext = (): PlotContextType | null => { - return useContext(PlotContext); +export const usePlotContext = (): PlotContextType => { + return useContext(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, + }, + }), }; }; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx new file mode 100644 index 00000000000..0c5c8ac6262 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx @@ -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 { + id: string; + events: DataFrame[]; + renderEventMarker: (event: T) => React.ReactNode; + mapEventToXYCoords: (event: T) => { x: number; y: number } | undefined; +} + +export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords }: EventsCanvasProps) { + 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(events[i]); + for (let j = 0; j < view.length; j++) { + const event = view.get(j); + + const coords = mapEventToXYCoords(event); + if (!coords) { + continue; + } + markers.push( + + {renderEventMarker(event)} + + ); + } + } + + return <>{markers}; + }, [events, renderEventMarker, renderToken, plotCtx.isPlotReady]); + + if (!plotCtx.isPlotReady) { + return null; + } + + return {eventMarkers}; +} diff --git a/packages/grafana-ui/src/components/uPlot/geometries/Marker.tsx b/packages/grafana-ui/src/components/uPlot/geometries/Marker.tsx new file mode 100644 index 00000000000..1e03e366b2e --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/Marker.tsx @@ -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 = ({ x, y, children }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/XYCanvas.tsx b/packages/grafana-ui/src/components/uPlot/geometries/XYCanvas.tsx new file mode 100644 index 00000000000..0ded2517c74 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/XYCanvas.tsx @@ -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 = ({ children }) => { + const plotContext = usePlotContext(); + + if (!plotContext.isPlotReady) { + return null; + } + + return ( +
+ {children} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/index.ts b/packages/grafana-ui/src/components/uPlot/geometries/index.ts index e42234239e0..83803af1947 100644 --- a/packages/grafana-ui/src/components/uPlot/geometries/index.ts +++ b/packages/grafana-ui/src/components/uPlot/geometries/index.ts @@ -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 }; diff --git a/packages/grafana-ui/src/components/uPlot/hooks.ts b/packages/grafana-ui/src/components/uPlot/hooks.ts index bf1cfab07d6..2b9b6518347 100644 --- a/packages/grafana-ui/src/components/uPlot/hooks.ts +++ b/packages/grafana-ui/src/components/uPlot/hooks.ts @@ -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; +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx index 39e2489971a..d4284c5a49f 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx @@ -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 = ({ onSelect, onDismiss, lazy, id, children }) => { const pluginId = `SelectionPlugin:${id}`; const pluginsApi = usePlotPluginContext(); - const canvas = usePlotCanvas(); - + const plotCtx = usePlotContext(); const [selection, setSelection] = useState(null); - // + useEffect(() => { if (!lazy && selection) { pluginLog(pluginId, false, 'selected', selection); @@ -77,7 +76,7 @@ export const SelectionPlugin: React.FC = ({ onSelect, onDi }; }, []); - if (!children || !canvas || !selection) { + if (!plotCtx.isPlotReady || !children || !selection) { return null; } diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index db3ccdfc3d7..be3cbb9f0d2 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -25,7 +25,7 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t return ( {({ focusedSeriesIdx, focusedPointIdx, coords }) => { - if (!plotContext || !plotContext.series) { + if (!plotContext.isPlotReady) { return null; } @@ -46,7 +46,7 @@ export const TooltipPlugin: React.FC = ({ 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 = ({ 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, diff --git a/public/app/plugins/panel/graph3/GraphPanel.tsx b/public/app/plugins/panel/graph3/GraphPanel.tsx index 08ab0b5ac05..aaa0944fdb5 100644 --- a/public/app/plugins/panel/graph3/GraphPanel.tsx +++ b/public/app/plugins/panel/graph3/GraphPanel.tsx @@ -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 {} @@ -238,6 +239,7 @@ export const GraphPanel: React.FC = ({ + {data.annotations && } {data.annotations && } {/* TODO: */} {/**/} diff --git a/public/app/plugins/panel/graph3/plugins/AnnotationMarker.tsx b/public/app/plugins/panel/graph3/plugins/AnnotationMarker.tsx index 57e51d5981c..7e720fb99e4 100644 --- a/public/app/plugins/panel/graph3/plugins/AnnotationMarker.tsx +++ b/public/app/plugins/panel/graph3/plugins/AnnotationMarker.tsx @@ -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 = ({ annotationEvent, x, formatTime }) => { +export const AnnotationMarker: React.FC = ({ time, text, tags }) => { const styles = useStyles(getAnnotationMarkerStyles); const [isOpen, setIsOpen] = useState(false); const markerRef = useRef(null); @@ -47,14 +47,14 @@ export const AnnotationMarker: React.FC = ({ annotationEv >
- {annotationEvent.title} - {annotationEvent.time && {formatTime(annotationEvent.time)}} + {/*{annotationEvent.title}*/} + {time && {time}}
- {annotationEvent.text &&
} + {text &&
} <> - {annotationEvent.tags?.map((t, i) => ( + {tags?.map((t, i) => ( ))} @@ -63,21 +63,11 @@ export const AnnotationMarker: React.FC = ({ annotationEv
); - }, [annotationEvent]); + }, [time, tags, text]); return ( <> -
+
{isOpen && {renderMarker()}} @@ -93,8 +83,6 @@ const getAnnotationMarkerStyles = (theme: GrafanaTheme) => { return { markerWrapper: css` padding: 0 4px 4px 4px; - position: absolute; - top: 0; `, marker: css` width: 0; diff --git a/public/app/plugins/panel/graph3/plugins/AnnotationsPlugin.tsx b/public/app/plugins/panel/graph3/plugins/AnnotationsPlugin.tsx index a0b299735ae..dbcdddc7054 100644 --- a/public/app/plugins/panel/graph3/plugins/AnnotationsPlugin.tsx +++ b/public/app/plugins/panel/graph3/plugins/AnnotationsPlugin.tsx @@ -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 = ({ annotations, timeZone }) => { const pluginId = 'AnnotationsPlugin'; + const plotCtx = usePlotContext(); const pluginsApi = usePlotPluginContext(); - const plotContext = usePlotContext(); - const annotationsRef = useRef(); - const [renderCounter, setRenderCounter] = useState(0); + const theme = useTheme(); + const annotationsRef = useRef>>(); const timeFormatter = useCallback( (value: number) => { @@ -29,54 +32,17 @@ export const AnnotationsPlugin: React.FC = ({ annotation [timeZone] ); - const annotationEventsStream = useMemo(() => getAnnotationsFromData(annotations), [annotations]); - const annotationsData = useObservable(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( - - ); - } - - return ( -
- {markers} -
- ); - }, [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> = []; + + 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 = ({ 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 = ({ annotation }; }, []); - if (!plotContext || !plotContext.u || !plotContext.canvas) { - return null; - } + const mapAnnotationToXYCoords = useCallback( + (annotation: AnnotationsDataFrameViewDTO) => { + if (!annotation.time) { + return undefined; + } - return
{annotationMarkers}
; + 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 ; + }, + [timeFormatter] + ); + + return ( + + id="annotations" + events={annotations} + renderEventMarker={renderMarker} + mapEventToXYCoords={mapAnnotationToXYCoords} + /> + ); }; diff --git a/public/app/plugins/panel/graph3/plugins/ExemplarMarker.tsx b/public/app/plugins/panel/graph3/plugins/ExemplarMarker.tsx new file mode 100644 index 00000000000..6482e62db5d --- /dev/null +++ b/public/app/plugins/panel/graph3/plugins/ExemplarMarker.tsx @@ -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 = ({ time, text, tags }) => { + const styles = useStyles(getExemplarMarkerStyles); + const [isOpen, setIsOpen] = useState(false); + const markerRef = useRef(null); + const annotationPopoverRef = useRef(null); + const popoverRenderTimeout = useRef(); + + 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 ( + +
+
+ {/*{exemplar.title}*/} + {time && {time}} +
+
+ {text &&
} + <> + + {tags?.map((t, i) => ( + + ))} + + +
+
+ + ); + }, [time, tags, text]); + + return ( + <> +
+ + + +
+ {isOpen && {renderMarker()}} + + ); +}; + +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}; + `, + }; +}; diff --git a/public/app/plugins/panel/graph3/plugins/ExemplarsPlugin.tsx b/public/app/plugins/panel/graph3/plugins/ExemplarsPlugin.tsx new file mode 100644 index 00000000000..0e9c9b088f5 --- /dev/null +++ b/public/app/plugins/panel/graph3/plugins/ExemplarsPlugin.tsx @@ -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 = ({ exemplars, timeZone }) => { + const plotCtx = usePlotContext(); + + // TEMPORARY MOCK + const [exemplarsMock, setExemplarsMock] = useState([]); + + 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 ; + }, + [timeFormatter] + ); + + return ( + + id="exemplars" + events={exemplarsMock} + renderEventMarker={renderMarker} + mapEventToXYCoords={mapExemplarToXYCoords} + /> + ); +};