mirror of https://github.com/grafana/grafana
Graph NG: annotations display (#27972)
* Annotations support POC * Fix markers memoization * dev dashboard update * Update public/app/plugins/panel/graph3/plugins/AnnotationsPlugin.tsxpull/28050/head
parent
a2816ee64a
commit
aa6c98f7ff
@ -0,0 +1,145 @@ |
||||
import React, { useCallback, useRef, useState } from 'react'; |
||||
import { AnnotationEvent, GrafanaTheme } from '@grafana/data'; |
||||
import { HorizontalGroup, Portal, Tag, TooltipContainer, useStyles } from '@grafana/ui'; |
||||
import { css, cx } from 'emotion'; |
||||
|
||||
interface AnnotationMarkerProps { |
||||
formatTime: (value: number) => string; |
||||
annotationEvent: AnnotationEvent; |
||||
x: number; |
||||
} |
||||
|
||||
export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ annotationEvent, x, formatTime }) => { |
||||
const styles = useStyles(getAnnotationMarkerStyles); |
||||
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}>{annotationEvent.title}</span> |
||||
{annotationEvent.time && <span className={styles.time}>{formatTime(annotationEvent.time)}</span>} |
||||
</div> |
||||
<div className={styles.body}> |
||||
{annotationEvent.text && <div dangerouslySetInnerHTML={{ __html: annotationEvent.text }} />} |
||||
<> |
||||
<HorizontalGroup spacing="xs" wrap> |
||||
{annotationEvent.tags?.map((t, i) => ( |
||||
<Tag name={t} key={`${t}-${i}`} /> |
||||
))} |
||||
</HorizontalGroup> |
||||
</> |
||||
</div> |
||||
</div> |
||||
</TooltipContainer> |
||||
); |
||||
}, [annotationEvent]); |
||||
|
||||
return ( |
||||
<> |
||||
<div |
||||
ref={markerRef} |
||||
onMouseEnter={onMouseEnter} |
||||
onMouseLeave={onMouseLeave} |
||||
className={cx( |
||||
styles.markerWrapper, |
||||
css` |
||||
left: ${x - 8}px; |
||||
` |
||||
)} |
||||
> |
||||
<div className={styles.marker} /> |
||||
</div> |
||||
{isOpen && <Portal>{renderMarker()}</Portal>} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const getAnnotationMarkerStyles = (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; |
||||
position: absolute; |
||||
top: 0; |
||||
`,
|
||||
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}; |
||||
`,
|
||||
}; |
||||
}; |
@ -0,0 +1,126 @@ |
||||
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 { AnnotationMarker } from './AnnotationMarker'; |
||||
|
||||
interface AnnotationsPluginProps { |
||||
annotations: DataFrame[]; |
||||
timeZone: TimeZone; |
||||
} |
||||
|
||||
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone }) => { |
||||
const pluginId = 'AnnotationsPlugin'; |
||||
const pluginsApi = usePlotPluginContext(); |
||||
const plotContext = usePlotContext(); |
||||
const annotationsRef = useRef<AnnotationEvent[] | null>(null); |
||||
const [renderCounter, setRenderCounter] = useState(0); |
||||
const theme = useTheme(); |
||||
|
||||
const timeFormatter = useCallback( |
||||
(value: number) => { |
||||
return dateTimeFormat(value, { |
||||
format: systemDateFormats.fullDate, |
||||
timeZone, |
||||
}); |
||||
}, |
||||
[timeZone] |
||||
); |
||||
|
||||
const annotationsData = useMemo(() => { |
||||
return getAnnotationsFromData(annotations); |
||||
}, [annotations]); |
||||
|
||||
const annotationMarkers = useMemo(() => { |
||||
if (!plotContext || !plotContext?.u) { |
||||
return null; |
||||
} |
||||
const 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]); |
||||
|
||||
useEffect(() => { |
||||
const unregister = pluginsApi.registerPlugin({ |
||||
id: pluginId, |
||||
hooks: { |
||||
// Render annotation lines on the canvas
|
||||
draw: u => { |
||||
/** |
||||
* We cannot rely on state value here, as it would require this effect to be dependent on the state value. |
||||
* This would make the plugin re-register making the entire plot to reinitialise. ref is the way to go :) |
||||
*/ |
||||
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 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; |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
return () => { |
||||
unregister(); |
||||
}; |
||||
}, []); |
||||
|
||||
if (!plotContext || !plotContext.u || !plotContext.canvas) { |
||||
return null; |
||||
} |
||||
|
||||
return <div>{annotationMarkers}</div>; |
||||
}; |
Loading…
Reference in new issue