mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
VizTooltips: Remove old tooltips and annotations (#84420)
Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
This commit is contained in:
parent
c9ab4e3a9e
commit
d601acac3a
@ -1166,9 +1166,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "8"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "9"]
|
||||
],
|
||||
"public/app/core/components/GraphNG/hooks.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/core/components/NestedFolderPicker/Trigger.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||
],
|
||||
@ -5872,29 +5869,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "6"]
|
||||
],
|
||||
"public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "4"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "6"]
|
||||
],
|
||||
"public/app/plugins/panel/timeseries/plugins/annotations/AnnotationTooltip.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "4"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "6"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "7"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "8"]
|
||||
],
|
||||
"public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
@ -5904,10 +5878,6 @@ exports[`better eslint`] = {
|
||||
"public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/timeseries/plugins/styles.ts:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||
],
|
||||
"public/app/plugins/panel/traces/TracesPanel.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||
],
|
||||
@ -5927,9 +5897,6 @@ exports[`better eslint`] = {
|
||||
"public/app/plugins/panel/xychart/ManualEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/xychart/TooltipView.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/xychart/XYChartPanel.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
|
@ -13,15 +13,22 @@ interface VizTooltipContentProps {
|
||||
children?: ReactNode;
|
||||
scrollable?: boolean;
|
||||
isPinned: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const VizTooltipContent = ({ items, children, isPinned, scrollable = false }: VizTooltipContentProps) => {
|
||||
export const VizTooltipContent = ({
|
||||
items,
|
||||
children,
|
||||
isPinned,
|
||||
scrollable = false,
|
||||
maxHeight,
|
||||
}: VizTooltipContentProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const scrollableStyle: CSSProperties = scrollable
|
||||
? {
|
||||
maxHeight: 400,
|
||||
overflowY: 'auto',
|
||||
maxHeight: maxHeight,
|
||||
overflowY: 'scroll',
|
||||
}
|
||||
: {};
|
||||
|
||||
|
@ -4,7 +4,7 @@ import uPlot, { Cursor, Band, Hooks, Select, AlignedData, Padding, Series } from
|
||||
import { DataFrame, DefaultTimeZone, Field, getTimeZoneInfo, GrafanaTheme2, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { AxisPlacement, VizOrientation } from '@grafana/schema';
|
||||
|
||||
import { FacetedData, PlotConfig, PlotTooltipInterpolator } from '../types';
|
||||
import { FacetedData, PlotConfig } from '../types';
|
||||
import { DEFAULT_PLOT_CONFIG, getStackingBands, pluginLog, StackingGroup } from '../utils';
|
||||
|
||||
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
||||
@ -30,9 +30,11 @@ type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData;
|
||||
type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData;
|
||||
|
||||
export class UPlotConfigBuilder {
|
||||
readonly uid = Math.random().toString(36).slice(2);
|
||||
|
||||
series: UPlotSeriesBuilder[] = [];
|
||||
private axes: Record<string, UPlotAxisBuilder> = {};
|
||||
private scales: UPlotScaleBuilder[] = [];
|
||||
readonly scales: UPlotScaleBuilder[] = [];
|
||||
private bands: Band[] = [];
|
||||
private stackingGroups: StackingGroup[] = [];
|
||||
private cursor: Cursor | undefined;
|
||||
@ -40,13 +42,10 @@ export class UPlotConfigBuilder {
|
||||
private hasLeftAxis = false;
|
||||
private hooks: Hooks.Arrays = {};
|
||||
private tz: string | undefined = undefined;
|
||||
private sync = false;
|
||||
private mode: uPlot.Mode = 1;
|
||||
private frames: DataFrame[] | undefined = undefined;
|
||||
// to prevent more than one threshold per scale
|
||||
private thresholds: Record<string, UPlotThresholdOptions> = {};
|
||||
// Custom handler for closest datapoint and series lookup
|
||||
private tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined;
|
||||
private padding?: Padding = undefined;
|
||||
|
||||
private cachedConfig?: PlotConfig;
|
||||
@ -155,14 +154,6 @@ export class UPlotConfigBuilder {
|
||||
return this.stackingGroups;
|
||||
}
|
||||
|
||||
setTooltipInterpolator(interpolator: PlotTooltipInterpolator) {
|
||||
this.tooltipInterpolator = interpolator;
|
||||
}
|
||||
|
||||
getTooltipInterpolator() {
|
||||
return this.tooltipInterpolator;
|
||||
}
|
||||
|
||||
setPrepData(prepData: PreDataStacked) {
|
||||
this.prepData = (frames) => {
|
||||
this.frames = frames;
|
||||
@ -170,14 +161,6 @@ export class UPlotConfigBuilder {
|
||||
};
|
||||
}
|
||||
|
||||
setSync() {
|
||||
this.sync = true;
|
||||
}
|
||||
|
||||
hasSync() {
|
||||
return this.sync;
|
||||
}
|
||||
|
||||
setPadding(padding: Padding) {
|
||||
this.padding = padding;
|
||||
}
|
||||
@ -301,8 +284,6 @@ type UPlotConfigPrepOpts<T extends Record<string, unknown> = {}> = {
|
||||
renderers?: Renderers;
|
||||
tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps;
|
||||
tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps;
|
||||
// Identifies the shared key for uPlot cursor sync
|
||||
eventsScope?: string;
|
||||
hoverProximity?: number;
|
||||
orientation?: VizOrientation;
|
||||
} & T;
|
||||
|
@ -1,160 +0,0 @@
|
||||
import { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
|
||||
import { CartesianCoords2D, DashboardCursorSync } from '@grafana/data';
|
||||
|
||||
import { positionTooltip } from '../plugins/TooltipPlugin';
|
||||
|
||||
import { UPlotConfigBuilder } from './UPlotConfigBuilder';
|
||||
|
||||
export type HoverEvent = {
|
||||
xIndex: number;
|
||||
yIndex: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
};
|
||||
|
||||
type SetupConfigParams = {
|
||||
config: UPlotConfigBuilder;
|
||||
onUPlotClick: () => void;
|
||||
setFocusedSeriesIdx: Dispatch<SetStateAction<number | null>>;
|
||||
setFocusedPointIdx: Dispatch<SetStateAction<number | null>>;
|
||||
setCoords: Dispatch<SetStateAction<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>>;
|
||||
setHover: Dispatch<SetStateAction<HoverEvent | undefined>>;
|
||||
isToolTipOpen: MutableRefObject<boolean>;
|
||||
isActive: boolean;
|
||||
setIsActive: Dispatch<SetStateAction<boolean>>;
|
||||
sync?: (() => DashboardCursorSync) | undefined;
|
||||
};
|
||||
|
||||
// This applies config hooks to setup tooltip listener. Ideally this could happen in the same `prepConfig` function
|
||||
// however the GraphNG structures do not allow access to the `setHover` callback
|
||||
export const addTooltipSupport = ({
|
||||
config,
|
||||
onUPlotClick,
|
||||
setFocusedSeriesIdx,
|
||||
setFocusedPointIdx,
|
||||
setCoords,
|
||||
setHover,
|
||||
isToolTipOpen,
|
||||
isActive,
|
||||
setIsActive,
|
||||
sync,
|
||||
}: SetupConfigParams): UPlotConfigBuilder => {
|
||||
// Ensure tooltip is closed on config changes
|
||||
isToolTipOpen.current = false;
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (setIsActive) {
|
||||
setIsActive(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
if (!isToolTipOpen.current) {
|
||||
setCoords(null);
|
||||
|
||||
if (setIsActive) {
|
||||
setIsActive(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let ref_parent: HTMLElement | null = null;
|
||||
let ref_over: HTMLElement | null = null;
|
||||
config.addHook('init', (u) => {
|
||||
ref_parent = u.root.parentElement;
|
||||
ref_over = u.over;
|
||||
ref_parent?.addEventListener('click', onUPlotClick);
|
||||
ref_over.addEventListener('mouseleave', onMouseLeave);
|
||||
ref_over.addEventListener('mouseenter', onMouseEnter);
|
||||
|
||||
if (sync && sync() === DashboardCursorSync.Crosshair) {
|
||||
u.root.classList.add('shared-crosshair');
|
||||
}
|
||||
});
|
||||
|
||||
const clearPopupIfOpened = () => {
|
||||
if (isToolTipOpen.current) {
|
||||
setCoords(null);
|
||||
onUPlotClick();
|
||||
}
|
||||
};
|
||||
|
||||
config.addHook('drawClear', clearPopupIfOpened);
|
||||
|
||||
config.addHook('destroy', () => {
|
||||
ref_parent?.removeEventListener('click', onUPlotClick);
|
||||
ref_over?.removeEventListener('mouseleave', onMouseLeave);
|
||||
ref_over?.removeEventListener('mouseenter', onMouseEnter);
|
||||
clearPopupIfOpened();
|
||||
});
|
||||
|
||||
let rect: DOMRect;
|
||||
// rect of .u-over (grid area)
|
||||
config.addHook('syncRect', (u, r) => {
|
||||
rect = r;
|
||||
});
|
||||
|
||||
const tooltipInterpolator = config.getTooltipInterpolator();
|
||||
if (tooltipInterpolator) {
|
||||
config.addHook('setCursor', (u) => {
|
||||
if (isToolTipOpen.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
tooltipInterpolator(
|
||||
setFocusedSeriesIdx,
|
||||
setFocusedPointIdx,
|
||||
(clear) => {
|
||||
if (clear) {
|
||||
setCoords(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = positionTooltip(u, rect);
|
||||
if (x !== undefined && y !== undefined) {
|
||||
setCoords({ canvas: { x: u.cursor.left!, y: u.cursor.top! }, viewport: { x, y } });
|
||||
}
|
||||
},
|
||||
u
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
config.addHook('setLegend', (u) => {
|
||||
if (!isToolTipOpen.current && !tooltipInterpolator) {
|
||||
setFocusedPointIdx(u.legend.idx!);
|
||||
}
|
||||
if (u.cursor.idxs != null) {
|
||||
for (let i = 0; i < u.cursor.idxs.length; i++) {
|
||||
const sel = u.cursor.idxs[i];
|
||||
if (sel != null) {
|
||||
const hover: HoverEvent = {
|
||||
xIndex: sel,
|
||||
yIndex: 0,
|
||||
pageX: rect.left + u.cursor.left!,
|
||||
pageY: rect.top + u.cursor.top!,
|
||||
};
|
||||
|
||||
if (!isToolTipOpen.current || !hover) {
|
||||
setHover(hover);
|
||||
}
|
||||
|
||||
return; // only show the first one
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
config.addHook('setSeries', (_, idx) => {
|
||||
if (!isToolTipOpen.current) {
|
||||
setFocusedSeriesIdx(idx);
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
@ -17,14 +17,13 @@ import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
interface EventBusPluginProps {
|
||||
config: UPlotConfigBuilder;
|
||||
eventBus: EventBus;
|
||||
sync: () => boolean;
|
||||
frame?: DataFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const EventBusPlugin = ({ config, eventBus, sync, frame }: EventBusPluginProps) => {
|
||||
export const EventBusPlugin = ({ config, eventBus, frame }: EventBusPluginProps) => {
|
||||
const frameRef = useRef<DataFrame | undefined>(frame);
|
||||
frameRef.current = frame;
|
||||
|
||||
@ -51,7 +50,7 @@ export const EventBusPlugin = ({ config, eventBus, sync, frame }: EventBusPlugin
|
||||
config.addHook('setLegend', () => {
|
||||
let viaSync = u!.cursor.event == null;
|
||||
|
||||
if (!viaSync && sync()) {
|
||||
if (!viaSync) {
|
||||
let dataIdx = u!.cursor.idxs!.find((v) => v != null);
|
||||
|
||||
if (dataIdx == null) {
|
||||
|
@ -1,333 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useMountedState } from 'react-use';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
arrayUtils,
|
||||
CartesianCoords2D,
|
||||
DashboardCursorSync,
|
||||
DataFrame,
|
||||
FALLBACK_COLOR,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getDisplayProcessor,
|
||||
getFieldDisplayName,
|
||||
GrafanaTheme2,
|
||||
TimeZone,
|
||||
} from '@grafana/data';
|
||||
import { TooltipDisplayMode, SortOrder } from '@grafana/schema';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
|
||||
import { Portal } from '../../Portal/Portal';
|
||||
import { SeriesTable, SeriesTableRowProps, VizTooltipContainer } from '../../VizTooltip';
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { findMidPointYPosition, pluginLog } from '../utils';
|
||||
|
||||
interface TooltipPluginProps {
|
||||
timeZone: TimeZone;
|
||||
data: DataFrame;
|
||||
frames?: DataFrame[];
|
||||
config: UPlotConfigBuilder;
|
||||
mode?: TooltipDisplayMode;
|
||||
sortOrder?: SortOrder;
|
||||
sync?: () => DashboardCursorSync;
|
||||
// Allows custom tooltip content rendering. Exposes aligned data frame with relevant indexes for data inspection
|
||||
// Use field.state.origin indexes from alignedData frame field to get access to original data frame and field index.
|
||||
renderTooltip?: (alignedFrame: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => React.ReactNode;
|
||||
}
|
||||
|
||||
const TOOLTIP_OFFSET = 10;
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const TooltipPlugin = ({
|
||||
mode = TooltipDisplayMode.Single,
|
||||
sortOrder = SortOrder.None,
|
||||
sync,
|
||||
timeZone,
|
||||
config,
|
||||
renderTooltip,
|
||||
...otherProps
|
||||
}: TooltipPluginProps) => {
|
||||
const plotInstance = useRef<uPlot>();
|
||||
const theme = useTheme2();
|
||||
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdxs, setFocusedPointIdxs] = useState<Array<number | null>>([]);
|
||||
const [coords, setCoords] = useState<CartesianCoords2D | null>(null);
|
||||
const [isActive, setIsActive] = useState<boolean>(false);
|
||||
const isMounted = useMountedState();
|
||||
let parentWithFocus: HTMLElement | null = null;
|
||||
|
||||
const pluginId = `TooltipPlugin`;
|
||||
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
// Debug logs
|
||||
useEffect(() => {
|
||||
pluginLog(pluginId, true, `Focused series: ${focusedSeriesIdx}, focused point: ${focusedPointIdx}`);
|
||||
}, [focusedPointIdx, focusedSeriesIdx]);
|
||||
|
||||
// Add uPlot hooks to the config, or re-add when the config changed
|
||||
useLayoutEffect(() => {
|
||||
let bbox: DOMRect | undefined = undefined;
|
||||
|
||||
const plotEnter = () => {
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
setIsActive(true);
|
||||
plotInstance.current?.root.classList.add('plot-active');
|
||||
};
|
||||
|
||||
const plotLeave = () => {
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
setCoords(null);
|
||||
setIsActive(false);
|
||||
plotInstance.current?.root.classList.remove('plot-active');
|
||||
};
|
||||
|
||||
// cache uPlot plotting area bounding box
|
||||
config.addHook('syncRect', (u, rect) => (bbox = rect));
|
||||
|
||||
config.addHook('init', (u) => {
|
||||
plotInstance.current = u;
|
||||
|
||||
u.over.addEventListener('mouseenter', plotEnter);
|
||||
u.over.addEventListener('mouseleave', plotLeave);
|
||||
|
||||
parentWithFocus = u.root.closest('[tabindex]');
|
||||
|
||||
if (parentWithFocus) {
|
||||
parentWithFocus.addEventListener('focus', plotEnter);
|
||||
parentWithFocus.addEventListener('blur', plotLeave);
|
||||
}
|
||||
|
||||
if (sync && sync() === DashboardCursorSync.Crosshair) {
|
||||
u.root.classList.add('shared-crosshair');
|
||||
}
|
||||
});
|
||||
|
||||
const tooltipInterpolator = config.getTooltipInterpolator();
|
||||
|
||||
if (tooltipInterpolator) {
|
||||
// Custom toolitp positioning
|
||||
config.addHook('setCursor', (u) => {
|
||||
tooltipInterpolator(
|
||||
setFocusedSeriesIdx,
|
||||
setFocusedPointIdx,
|
||||
(clear) => {
|
||||
if (clear) {
|
||||
setCoords(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = positionTooltip(u, bbox);
|
||||
if (x !== undefined && y !== undefined) {
|
||||
setCoords({ x, y });
|
||||
}
|
||||
},
|
||||
u
|
||||
);
|
||||
});
|
||||
} else {
|
||||
config.addHook('setLegend', (u) => {
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
setFocusedPointIdx(u.legend.idx!);
|
||||
setFocusedPointIdxs(u.legend.idxs!.slice());
|
||||
});
|
||||
|
||||
// default series/datapoint idx retireval
|
||||
config.addHook('setCursor', (u) => {
|
||||
if (!bbox || !isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = positionTooltip(u, bbox);
|
||||
if (x !== undefined && y !== undefined) {
|
||||
setCoords({ x, y });
|
||||
} else {
|
||||
setCoords(null);
|
||||
}
|
||||
});
|
||||
|
||||
config.addHook('setSeries', (_, idx) => {
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
setFocusedSeriesIdx(idx);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
setCoords(null);
|
||||
|
||||
if (plotInstance.current) {
|
||||
plotInstance.current.over.removeEventListener('mouseleave', plotLeave);
|
||||
plotInstance.current.over.removeEventListener('mouseenter', plotEnter);
|
||||
|
||||
if (parentWithFocus) {
|
||||
parentWithFocus.removeEventListener('focus', plotEnter);
|
||||
parentWithFocus.removeEventListener('blur', plotLeave);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [config, setCoords, setIsActive, setFocusedPointIdx, setFocusedPointIdxs]);
|
||||
|
||||
if (focusedPointIdx === null || (!isActive && sync && sync() === DashboardCursorSync.Crosshair)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// GraphNG expects aligned data, let's take field 0 as x field. FTW
|
||||
let xField = otherProps.data.fields[0];
|
||||
if (!xField) {
|
||||
return null;
|
||||
}
|
||||
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
|
||||
let tooltip: React.ReactNode = null;
|
||||
|
||||
let xVal = xFieldFmt(xField!.values[focusedPointIdx]).text;
|
||||
|
||||
if (!renderTooltip) {
|
||||
// when interacting with a point in single mode
|
||||
if (mode === TooltipDisplayMode.Single && focusedSeriesIdx !== null) {
|
||||
const field = otherProps.data.fields[focusedSeriesIdx];
|
||||
|
||||
if (!field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataIdx = focusedPointIdxs?.[focusedSeriesIdx] ?? focusedPointIdx;
|
||||
xVal = xFieldFmt(xField!.values[dataIdx]).text;
|
||||
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
|
||||
const display = fieldFmt(field.values[dataIdx]);
|
||||
|
||||
tooltip = (
|
||||
<SeriesTable
|
||||
series={[
|
||||
{
|
||||
color: display.color || FALLBACK_COLOR,
|
||||
label: getFieldDisplayName(field, otherProps.data, otherProps.frames),
|
||||
value: display ? formattedValueToString(display) : null,
|
||||
},
|
||||
]}
|
||||
timestamp={xVal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === TooltipDisplayMode.Multi) {
|
||||
let series: SeriesTableRowProps[] = [];
|
||||
const frame = otherProps.data;
|
||||
const fields = frame.fields;
|
||||
const sortIdx: unknown[] = [];
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = frame.fields[i];
|
||||
if (
|
||||
!field ||
|
||||
field === xField ||
|
||||
field.type === FieldType.time ||
|
||||
field.type !== FieldType.number ||
|
||||
field.config.custom?.hideFrom?.tooltip ||
|
||||
field.config.custom?.hideFrom?.viz
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const v = otherProps.data.fields[i].values[focusedPointIdxs[i]!];
|
||||
const display = field.display!(v);
|
||||
|
||||
sortIdx.push(v);
|
||||
series.push({
|
||||
color: display.color || FALLBACK_COLOR,
|
||||
label: getFieldDisplayName(field, frame, otherProps.frames),
|
||||
value: display ? formattedValueToString(display) : null,
|
||||
isActive: focusedSeriesIdx === i,
|
||||
});
|
||||
}
|
||||
|
||||
if (sortOrder !== SortOrder.None) {
|
||||
// create sort reference series array, as Array.sort() mutates the original array
|
||||
const sortRef = [...series];
|
||||
const sortFn = arrayUtils.sortValues(sortOrder);
|
||||
|
||||
series.sort((a, b) => {
|
||||
// get compared values indices to retrieve raw values from sortIdx
|
||||
const aIdx = sortRef.indexOf(a);
|
||||
const bIdx = sortRef.indexOf(b);
|
||||
return sortFn(sortIdx[aIdx], sortIdx[bIdx]);
|
||||
});
|
||||
}
|
||||
|
||||
tooltip = <SeriesTable series={series} timestamp={xVal} />;
|
||||
}
|
||||
} else {
|
||||
tooltip = renderTooltip(otherProps.data, focusedSeriesIdx, focusedPointIdx);
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal className={isActive ? style.tooltipWrapper : undefined}>
|
||||
{tooltip && coords && (
|
||||
<VizTooltipContainer position={{ x: coords.x, y: coords.y }} offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}>
|
||||
{tooltip}
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
function isCursorOutsideCanvas({ left, top }: uPlot.Cursor, canvas: DOMRect) {
|
||||
if (left === undefined || top === undefined) {
|
||||
return false;
|
||||
}
|
||||
return left < 0 || left > canvas.width || top < 0 || top > canvas.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given uPlot cursor position, figure out position of the tooltip withing the canvas bbox
|
||||
* Tooltip is positioned relatively to a viewport
|
||||
* @internal
|
||||
**/
|
||||
export function positionTooltip(u: uPlot, bbox: DOMRect) {
|
||||
let x, y;
|
||||
const cL = u.cursor.left || 0;
|
||||
const cT = u.cursor.top || 0;
|
||||
|
||||
if (isCursorOutsideCanvas(u.cursor, bbox)) {
|
||||
const idx = u.posToIdx(cL);
|
||||
// when cursor outside of uPlot's canvas
|
||||
if (cT < 0 || cT > bbox.height) {
|
||||
let pos = findMidPointYPosition(u, idx);
|
||||
|
||||
if (pos) {
|
||||
y = bbox.top + pos;
|
||||
if (cL >= 0 && cL <= bbox.width) {
|
||||
// find x-scale position for a current cursor left position
|
||||
x = bbox.left + u.valToPos(u.data[0][u.posToIdx(cL)], u.series[0].scale!);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
x = bbox.left + cL;
|
||||
y = bbox.top + cT;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tooltipWrapper: css({
|
||||
'z-index': theme.zIndex.portal + 1 + ' !important',
|
||||
}),
|
||||
});
|
@ -4,6 +4,7 @@ import { createPortal } from 'react-dom';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { DashboardCursorSync } from '@grafana/schema';
|
||||
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { getPortalContainer } from '../../Portal/Portal';
|
||||
@ -29,7 +30,8 @@ interface TooltipPlugin2Props {
|
||||
config: UPlotConfigBuilder;
|
||||
hoverMode: TooltipHoverMode;
|
||||
|
||||
syncTooltip?: () => boolean;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncScope?: string;
|
||||
|
||||
// x only
|
||||
queryZoom?: (range: { from: number; to: number }) => void;
|
||||
@ -48,7 +50,6 @@ interface TooltipPlugin2Props {
|
||||
) => React.ReactNode;
|
||||
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
interface TooltipContainerState {
|
||||
@ -108,8 +109,8 @@ export const TooltipPlugin2 = ({
|
||||
clientZoom = false,
|
||||
queryZoom,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
syncTooltip = () => false,
|
||||
syncMode = DashboardCursorSync.Off,
|
||||
syncScope = 'global', // eventsScope
|
||||
}: TooltipPlugin2Props) => {
|
||||
const domRef = useRef<HTMLDivElement>(null);
|
||||
const portalRoot = useRef<HTMLElement | null>(null);
|
||||
@ -123,8 +124,7 @@ export const TooltipPlugin2 = ({
|
||||
const sizeRef = useRef<TooltipContainerSize>();
|
||||
|
||||
maxWidth = isPinned ? DEFAULT_TOOLTIP_WIDTH : maxWidth ?? DEFAULT_TOOLTIP_WIDTH;
|
||||
maxHeight ??= DEFAULT_TOOLTIP_HEIGHT;
|
||||
const styles = useStyles2(getStyles, maxWidth, maxHeight);
|
||||
const styles = useStyles2(getStyles, maxWidth);
|
||||
|
||||
const renderRef = useRef(render);
|
||||
renderRef.current = render;
|
||||
@ -159,9 +159,20 @@ export const TooltipPlugin2 = ({
|
||||
|
||||
let plotVisible = false;
|
||||
|
||||
const syncTooltip = syncMode === DashboardCursorSync.Tooltip;
|
||||
|
||||
if (syncMode !== DashboardCursorSync.Off && config.scales[0].props.isTime) {
|
||||
config.setCursor({
|
||||
sync: {
|
||||
key: syncScope,
|
||||
scales: ['x', null],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updateHovering = () => {
|
||||
if (viaSync) {
|
||||
_isHovering = plotVisible && _someSeriesIdx && syncTooltip();
|
||||
_isHovering = plotVisible && _someSeriesIdx && syncTooltip;
|
||||
} else {
|
||||
_isHovering = closestSeriesIdx != null || (hoverMode === TooltipHoverMode.xAll && _someSeriesIdx);
|
||||
}
|
||||
@ -570,7 +581,7 @@ export const TooltipPlugin2 = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, maxWidth?: number, maxHeight?: number) => ({
|
||||
const getStyles = (theme: GrafanaTheme2, maxWidth?: number) => ({
|
||||
tooltipWrapper: css({
|
||||
top: 0,
|
||||
left: 0,
|
||||
@ -583,8 +594,6 @@ const getStyles = (theme: GrafanaTheme2, maxWidth?: number, maxHeight?: number)
|
||||
boxShadow: theme.shadows.z2,
|
||||
userSelect: 'text',
|
||||
maxWidth: maxWidth ?? 'none',
|
||||
maxHeight: maxHeight ?? 'none',
|
||||
overflowY: 'auto',
|
||||
}),
|
||||
pinned: css({
|
||||
boxShadow: theme.shadows.z3,
|
||||
|
@ -1,127 +0,0 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
|
||||
interface ZoomPluginProps {
|
||||
onZoom: (range: { from: number; to: number }) => void;
|
||||
withZoomY?: boolean;
|
||||
config: UPlotConfigBuilder;
|
||||
}
|
||||
|
||||
// min px width that triggers zoom
|
||||
const MIN_ZOOM_DIST = 5;
|
||||
|
||||
const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey;
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const ZoomPlugin = ({ onZoom, config, withZoomY = false }: ZoomPluginProps) => {
|
||||
useLayoutEffect(() => {
|
||||
let yZoomed = false;
|
||||
let yDrag = false;
|
||||
|
||||
if (withZoomY) {
|
||||
config.addHook('init', (u) => {
|
||||
u.over!.addEventListener(
|
||||
'mousedown',
|
||||
(e) => {
|
||||
if (!maybeZoomAction(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.button === 0 && e.shiftKey) {
|
||||
yDrag = true;
|
||||
|
||||
u.cursor!.drag!.x = false;
|
||||
u.cursor!.drag!.y = true;
|
||||
|
||||
let onUp = (e: MouseEvent) => {
|
||||
u.cursor!.drag!.x = true;
|
||||
u.cursor!.drag!.y = false;
|
||||
document.removeEventListener('mouseup', onUp, true);
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', onUp, true);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
config.addHook('setSelect', (u) => {
|
||||
const isXAxisHorizontal = u.scales.x.ori === 0;
|
||||
if (maybeZoomAction(u.cursor!.event)) {
|
||||
if (withZoomY && yDrag) {
|
||||
if (u.select.height >= MIN_ZOOM_DIST) {
|
||||
for (let key in u.scales!) {
|
||||
if (key !== 'x') {
|
||||
const maxY = isXAxisHorizontal
|
||||
? u.posToVal(u.select.top, key)
|
||||
: u.posToVal(u.select.left + u.select.width, key);
|
||||
const minY = isXAxisHorizontal
|
||||
? u.posToVal(u.select.top + u.select.height, key)
|
||||
: u.posToVal(u.select.left, key);
|
||||
u.setScale(key, { min: minY, max: maxY });
|
||||
}
|
||||
}
|
||||
|
||||
yZoomed = true;
|
||||
}
|
||||
|
||||
yDrag = false;
|
||||
} else {
|
||||
if (u.select.width >= MIN_ZOOM_DIST) {
|
||||
const minX = isXAxisHorizontal
|
||||
? u.posToVal(u.select.left, 'x')
|
||||
: u.posToVal(u.select.top + u.select.height, 'x');
|
||||
const maxX = isXAxisHorizontal
|
||||
? u.posToVal(u.select.left + u.select.width, 'x')
|
||||
: u.posToVal(u.select.top, 'x');
|
||||
|
||||
onZoom({ from: minX, to: maxX });
|
||||
|
||||
yZoomed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// manually hide selected region (since cursor.drag.setScale = false)
|
||||
u.setSelect({ left: 0, width: 0, top: 0, height: 0 }, false);
|
||||
});
|
||||
|
||||
config.setCursor({
|
||||
bind: {
|
||||
dblclick: (u) => () => {
|
||||
if (!maybeZoomAction(u.cursor!.event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (withZoomY && yZoomed) {
|
||||
for (let key in u.scales!) {
|
||||
if (key !== 'x') {
|
||||
// @ts-ignore (this is not typed correctly in uPlot, assigning nulls means auto-scale / reset)
|
||||
u.setScale(key, { min: null, max: null });
|
||||
}
|
||||
}
|
||||
|
||||
yZoomed = false;
|
||||
} else {
|
||||
let xScale = u.scales.x;
|
||||
|
||||
const frTs = xScale.min!;
|
||||
const toTs = xScale.max!;
|
||||
const pad = (toTs - frTs) / 2;
|
||||
|
||||
onZoom({ from: frTs - pad, to: toTs + pad });
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
return null;
|
||||
};
|
@ -1,5 +1,3 @@
|
||||
export { ZoomPlugin } from './ZoomPlugin';
|
||||
export { TooltipPlugin } from './TooltipPlugin';
|
||||
export { TooltipPlugin2 } from './TooltipPlugin2';
|
||||
export { EventBusPlugin } from './EventBusPlugin';
|
||||
export { KeyboardPlugin } from './KeyboardPlugin';
|
||||
|
@ -13,10 +13,6 @@ export type PlotConfig = Pick<
|
||||
'mode' | 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding'
|
||||
>;
|
||||
|
||||
export interface PlotPluginProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type FacetValues = any[];
|
||||
export type FacetSeries = FacetValues[];
|
||||
export type FacetedData = [_: null, ...series: FacetSeries];
|
||||
|
@ -3,21 +3,7 @@ import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, Stacki
|
||||
|
||||
import { preparePlotFrame } from '..';
|
||||
|
||||
import { getStackingGroups, preparePlotData2, timeFormatToTemplate } from './utils';
|
||||
|
||||
describe('timeFormatToTemplate', () => {
|
||||
it.each`
|
||||
format | expected
|
||||
${'HH:mm:ss'} | ${'{HH}:{mm}:{ss}'}
|
||||
${'HH:mm'} | ${'{HH}:{mm}'}
|
||||
${'MM/DD HH:mm'} | ${'{MM}/{DD} {HH}:{mm}'}
|
||||
${'MM/DD'} | ${'{MM}/{DD}'}
|
||||
${'YYYY-MM'} | ${'{YYYY}-{MM}'}
|
||||
${'YYYY'} | ${'{YYYY}'}
|
||||
`('should convert $format to $expected', ({ format, expected }) => {
|
||||
expect(timeFormatToTemplate(format)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
import { getStackingGroups, preparePlotData2 } from './utils';
|
||||
|
||||
describe('preparePlotData2', () => {
|
||||
const df = new MutableDataFrame({
|
||||
|
@ -8,12 +8,6 @@ import { createLogger } from '../../utils/logger';
|
||||
|
||||
import { buildScaleKey } from './internal';
|
||||
|
||||
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
|
||||
|
||||
export function timeFormatToTemplate(f: string) {
|
||||
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`);
|
||||
}
|
||||
|
||||
const paddingSide: PaddingSide = (u, side, sidesWithAxes) => {
|
||||
let hasCrossAxis = side % 2 ? sidesWithAxes[0] || sidesWithAxes[2] : sidesWithAxes[1] || sidesWithAxes[3];
|
||||
|
||||
|
@ -74,13 +74,6 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
|
||||
"stroke": [Function],
|
||||
"width": [Function],
|
||||
},
|
||||
"sync": {
|
||||
"key": "__global_",
|
||||
"scales": [
|
||||
"x",
|
||||
null,
|
||||
],
|
||||
},
|
||||
},
|
||||
"focus": {
|
||||
"alpha": 1,
|
||||
|
@ -1,9 +1,7 @@
|
||||
import {
|
||||
createTheme,
|
||||
DashboardCursorSync,
|
||||
DataFrame,
|
||||
DefaultTimeZone,
|
||||
// EventBusSrv,
|
||||
FieldColorModeId,
|
||||
FieldConfig,
|
||||
FieldMatcherID,
|
||||
@ -215,7 +213,6 @@ describe('GraphNG utils', () => {
|
||||
theme: createTheme(),
|
||||
timeZones: [DefaultTimeZone],
|
||||
getTimeRange: getDefaultTimeRange,
|
||||
sync: () => DashboardCursorSync.Tooltip,
|
||||
allFrames: [frame!],
|
||||
}).getConfig();
|
||||
expect(result).toMatchSnapshot();
|
||||
|
@ -19,7 +19,6 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
|
||||
declare context: React.ContextType<typeof PanelContextRoot>;
|
||||
|
||||
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
|
||||
const { eventsScope, sync } = this.context;
|
||||
const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props;
|
||||
|
||||
return preparePlotConfigBuilder({
|
||||
@ -27,12 +26,10 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
|
||||
theme,
|
||||
timeZones: Array.isArray(timeZone) ? timeZone : [timeZone],
|
||||
getTimeRange,
|
||||
sync,
|
||||
allFrames,
|
||||
renderers,
|
||||
tweakScale,
|
||||
tweakAxis,
|
||||
eventsScope,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { isNumber } from 'lodash';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
DataFrame,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
@ -71,19 +70,15 @@ const defaultConfig: GraphFieldConfig = {
|
||||
axisPlacement: AxisPlacement.Auto,
|
||||
};
|
||||
|
||||
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
|
||||
sync?: () => DashboardCursorSync;
|
||||
}> = ({
|
||||
export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
|
||||
frame,
|
||||
theme,
|
||||
timeZones,
|
||||
getTimeRange,
|
||||
sync,
|
||||
allFrames,
|
||||
renderers,
|
||||
tweakScale = (opts) => opts,
|
||||
tweakAxis = (opts) => opts,
|
||||
eventsScope = '__global_',
|
||||
}) => {
|
||||
const builder = new UPlotConfigBuilder(timeZones[0]);
|
||||
|
||||
@ -103,7 +98,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
|
||||
}
|
||||
|
||||
const xScaleKey = 'x';
|
||||
let yScaleKey = '';
|
||||
|
||||
const xFieldAxisPlacement =
|
||||
xField.config.custom?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden;
|
||||
@ -264,10 +258,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
|
||||
)
|
||||
);
|
||||
|
||||
if (!yScaleKey) {
|
||||
yScaleKey = scaleKey;
|
||||
}
|
||||
|
||||
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
let axisColor: uPlot.Axis.Stroke | undefined;
|
||||
|
||||
@ -543,8 +533,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
|
||||
r.init(builder, fieldIndices);
|
||||
});
|
||||
|
||||
builder.scaleKeys = [xScaleKey, yScaleKey];
|
||||
|
||||
// if hovered value is null, how far we may scan left/right to hover nearest non-null
|
||||
const hoverProximityPx = 15;
|
||||
|
||||
@ -597,20 +585,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
|
||||
},
|
||||
};
|
||||
|
||||
if (sync && sync() !== DashboardCursorSync.Off) {
|
||||
cursor.sync = {
|
||||
key: eventsScope,
|
||||
scales: [xScaleKey, null],
|
||||
};
|
||||
}
|
||||
|
||||
builder.setSync();
|
||||
builder.setCursor(cursor);
|
||||
|
||||
return builder;
|
||||
};
|
||||
|
||||
export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
|
||||
function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
|
||||
const originNames = new Map<string, number>();
|
||||
frame.fields.forEach((field, i) => {
|
||||
const origin = field.state?.origin;
|
||||
|
@ -72,10 +72,10 @@ export function addTooltipOptions<T extends OptionsWithTooltip>(
|
||||
path: 'tooltip.maxHeight',
|
||||
name: 'Max height',
|
||||
category,
|
||||
defaultValue: 600,
|
||||
defaultValue: undefined,
|
||||
settings: {
|
||||
integer: true,
|
||||
},
|
||||
showIf: (options: T) => false, //options.tooltip?.mode !== TooltipDisplayMode.None,
|
||||
showIf: (options: T) => options.tooltip?.mode !== TooltipDisplayMode.None,
|
||||
});
|
||||
}
|
||||
|
@ -13,8 +13,8 @@ import {
|
||||
TimeRange,
|
||||
TimeZone,
|
||||
} from '@grafana/data';
|
||||
import { VizLegendOptions } from '@grafana/schema';
|
||||
import { Themeable2, PanelContextRoot, VizLayout } from '@grafana/ui';
|
||||
import { DashboardCursorSync, VizLegendOptions } from '@grafana/schema';
|
||||
import { Themeable2, VizLayout } from '@grafana/ui';
|
||||
import { UPlotChart } from '@grafana/ui/src/components/uPlot/Plot';
|
||||
import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
|
||||
import { Renderers, UPlotConfigBuilder } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder';
|
||||
@ -49,6 +49,7 @@ export interface GraphNGProps extends Themeable2 {
|
||||
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null;
|
||||
replaceVariables: InterpolateFunction;
|
||||
dataLinkPostProcessor?: DataLinkPostProcessor;
|
||||
cursorSync?: DashboardCursorSync;
|
||||
|
||||
/**
|
||||
* needed for propsToDiff to re-init the plot & config
|
||||
@ -86,7 +87,6 @@ export interface GraphNGState {
|
||||
* "Time as X" core component, expects ascending x
|
||||
*/
|
||||
export class GraphNG extends Component<GraphNGProps, GraphNGState> {
|
||||
static contextType = PanelContextRoot;
|
||||
private plotInstance: React.RefObject<uPlot>;
|
||||
|
||||
constructor(props: GraphNGProps) {
|
||||
@ -173,17 +173,23 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: GraphNGProps) {
|
||||
const { frames, structureRev, timeZone, propsToDiff } = this.props;
|
||||
const { frames, structureRev, timeZone, cursorSync, propsToDiff } = this.props;
|
||||
|
||||
const propsChanged = !sameProps(prevProps, this.props, propsToDiff);
|
||||
|
||||
if (frames !== prevProps.frames || propsChanged || timeZone !== prevProps.timeZone) {
|
||||
if (
|
||||
frames !== prevProps.frames ||
|
||||
propsChanged ||
|
||||
timeZone !== prevProps.timeZone ||
|
||||
cursorSync !== prevProps.cursorSync
|
||||
) {
|
||||
let newState = this.prepState(this.props, false);
|
||||
|
||||
if (newState) {
|
||||
const shouldReconfig =
|
||||
this.state.config === undefined ||
|
||||
timeZone !== prevProps.timeZone ||
|
||||
cursorSync !== prevProps.cursorSync ||
|
||||
structureRev !== prevProps.structureRev ||
|
||||
!structureRev ||
|
||||
propsChanged;
|
||||
|
@ -79,13 +79,6 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
|
||||
"stroke": [Function],
|
||||
"width": [Function],
|
||||
},
|
||||
"sync": {
|
||||
"key": "__global_",
|
||||
"scales": [
|
||||
"x",
|
||||
null,
|
||||
],
|
||||
},
|
||||
},
|
||||
"focus": {
|
||||
"alpha": 1,
|
||||
|
@ -1,44 +0,0 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
|
||||
import { DataFrame, DataFrameFieldIndex, Field } from '@grafana/data';
|
||||
|
||||
import { XYFieldMatchers } from './types';
|
||||
|
||||
/** @alpha */
|
||||
interface GraphNGContextType {
|
||||
mapSeriesIndexToDataFrameFieldIndex: (index: number) => DataFrameFieldIndex;
|
||||
dimFields: XYFieldMatchers;
|
||||
data: DataFrame;
|
||||
}
|
||||
|
||||
/** @alpha */
|
||||
export const GraphNGContext = React.createContext<GraphNGContextType>({} as GraphNGContextType);
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* Exposes API for data frame inspection in Plot plugins
|
||||
*/
|
||||
export const useGraphNGContext = () => {
|
||||
const { data, dimFields, mapSeriesIndexToDataFrameFieldIndex } = useContext<GraphNGContextType>(GraphNGContext);
|
||||
|
||||
const getXAxisField = useCallback(() => {
|
||||
const xFieldMatcher = dimFields.x;
|
||||
let xField: Field | null = null;
|
||||
|
||||
for (let j = 0; j < data.fields.length; j++) {
|
||||
if (xFieldMatcher(data.fields[j], data, [data])) {
|
||||
xField = data.fields[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return xField;
|
||||
}, [data, dimFields]);
|
||||
|
||||
return {
|
||||
dimFields,
|
||||
mapSeriesIndexToDataFrameFieldIndex,
|
||||
getXAxisField,
|
||||
alignedData: data,
|
||||
};
|
||||
};
|
@ -1,6 +1,5 @@
|
||||
import {
|
||||
createTheme,
|
||||
DashboardCursorSync,
|
||||
DataFrame,
|
||||
DefaultTimeZone,
|
||||
FieldColorModeId,
|
||||
@ -214,7 +213,6 @@ describe('GraphNG utils', () => {
|
||||
theme: createTheme(),
|
||||
timeZones: [DefaultTimeZone],
|
||||
getTimeRange: getDefaultTimeRange,
|
||||
sync: () => DashboardCursorSync.Tooltip,
|
||||
allFrames: [frame!],
|
||||
}).getConfig();
|
||||
expect(result).toMatchSnapshot();
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { DataFrame, TimeRange } from '@grafana/data';
|
||||
import { PanelContextRoot } from '@grafana/ui/src/components/PanelChrome/PanelContext';
|
||||
import { hasVisibleLegendSeries, PlotLegend } from '@grafana/ui/src/components/uPlot/PlotLegend';
|
||||
import { UPlotConfigBuilder } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder';
|
||||
import { withTheme2 } from '@grafana/ui/src/themes/ThemeContext';
|
||||
@ -15,11 +14,7 @@ const propsToDiff: Array<string | PropDiffFn> = ['legend', 'options', 'theme'];
|
||||
type TimeSeriesProps = Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'>;
|
||||
|
||||
export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
|
||||
static contextType = PanelContextRoot;
|
||||
declare context: React.ContextType<typeof PanelContextRoot>;
|
||||
|
||||
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
|
||||
const { eventsScope, sync } = this.context;
|
||||
const { theme, timeZone, options, renderers, tweakAxis, tweakScale } = this.props;
|
||||
|
||||
return preparePlotConfigBuilder({
|
||||
@ -27,12 +22,10 @@ export class UnthemedTimeSeries extends Component<TimeSeriesProps> {
|
||||
theme,
|
||||
timeZones: Array.isArray(timeZone) ? timeZone : [timeZone],
|
||||
getTimeRange,
|
||||
sync,
|
||||
allFrames,
|
||||
renderers,
|
||||
tweakScale,
|
||||
tweakAxis,
|
||||
eventsScope,
|
||||
hoverProximity: options?.tooltip?.hoverProximity,
|
||||
orientation: options?.orientation,
|
||||
});
|
||||
|
@ -2,7 +2,6 @@ import { isNumber } from 'lodash';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
DataFrame,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
@ -73,19 +72,15 @@ const defaultConfig: GraphFieldConfig = {
|
||||
axisPlacement: AxisPlacement.Auto,
|
||||
};
|
||||
|
||||
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
|
||||
sync?: () => DashboardCursorSync;
|
||||
}> = ({
|
||||
export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({
|
||||
frame,
|
||||
theme,
|
||||
timeZones,
|
||||
getTimeRange,
|
||||
sync,
|
||||
allFrames,
|
||||
renderers,
|
||||
tweakScale = (opts) => opts,
|
||||
tweakAxis = (opts) => opts,
|
||||
eventsScope = '__global_',
|
||||
hoverProximity,
|
||||
orientation = VizOrientation.Horizontal,
|
||||
}) => {
|
||||
@ -555,8 +550,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
|
||||
r.init(builder, fieldIndices);
|
||||
});
|
||||
|
||||
builder.scaleKeys = [xScaleKey, yScaleKey];
|
||||
|
||||
// if hovered value is null, how far we may scan left/right to hover nearest non-null
|
||||
const DEFAULT_HOVER_NULL_PROXIMITY = 15;
|
||||
const DEFAULT_FOCUS_PROXIMITY = 30;
|
||||
@ -586,21 +579,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
|
||||
},
|
||||
};
|
||||
|
||||
if (xField.type === FieldType.time && sync && sync() !== DashboardCursorSync.Off) {
|
||||
cursor.sync = {
|
||||
key: eventsScope,
|
||||
scales: [xScaleKey, null],
|
||||
// match: [() => true, () => false],
|
||||
};
|
||||
}
|
||||
|
||||
builder.setSync();
|
||||
builder.setCursor(cursor);
|
||||
|
||||
return builder;
|
||||
};
|
||||
|
||||
export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
|
||||
function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
|
||||
const originNames = new Map<string, number>();
|
||||
frame.fields.forEach((field, i) => {
|
||||
const origin = field.state?.origin;
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data';
|
||||
import { VisibilityMode, TimelineValueAlignment, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
|
||||
import { PanelContext, PanelContextRoot, UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui';
|
||||
import { UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui';
|
||||
|
||||
import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG';
|
||||
|
||||
@ -24,10 +24,6 @@ export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsT
|
||||
const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue', 'tooltip'];
|
||||
|
||||
export class TimelineChart extends React.Component<TimelineProps> {
|
||||
declare context: React.ContextType<typeof PanelContextRoot>;
|
||||
static contextType = PanelContextRoot;
|
||||
panelContext: PanelContext | undefined;
|
||||
|
||||
getValueColor = (frameIdx: number, fieldIdx: number, value: unknown) => {
|
||||
const field = this.props.frames[frameIdx].fields[fieldIdx];
|
||||
|
||||
@ -42,13 +38,9 @@ export class TimelineChart extends React.Component<TimelineProps> {
|
||||
};
|
||||
|
||||
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
|
||||
this.panelContext = this.context;
|
||||
const { sync } = this.panelContext;
|
||||
|
||||
return preparePlotConfigBuilder({
|
||||
frame: alignedFrame,
|
||||
getTimeRange,
|
||||
sync,
|
||||
allFrames: this.props.frames,
|
||||
...this.props,
|
||||
|
||||
@ -92,7 +84,6 @@ export class TimelineChart extends React.Component<TimelineProps> {
|
||||
prepConfig={this.prepConfig}
|
||||
propsToDiff={propsToDiff}
|
||||
renderLegend={this.renderLegend}
|
||||
dataLinkPostProcessor={this.panelContext?.dataLinkPostProcessor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -53,8 +53,6 @@ export interface TimelineCoreOptions {
|
||||
getTimeRange: () => TimeRange;
|
||||
formatValue?: (seriesIdx: number, value: unknown) => string;
|
||||
getFieldConfig: (seriesIdx: number) => StateTimeLineFieldConfig | StatusHistoryFieldConfig;
|
||||
onHover: (seriesIdx: number, valueIdx: number, rect: Rect) => void;
|
||||
onLeave: () => void;
|
||||
hoverMulti: boolean;
|
||||
}
|
||||
|
||||
@ -78,8 +76,6 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
getTimeRange,
|
||||
getValueColor,
|
||||
getFieldConfig,
|
||||
onHover,
|
||||
onLeave,
|
||||
hoverMulti,
|
||||
} = opts;
|
||||
|
||||
@ -426,17 +422,7 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
let cx = u.cursor.left! * uPlot.pxRatio;
|
||||
let cy = u.cursor.top! * uPlot.pxRatio;
|
||||
|
||||
let prevHovered = hoveredAtCursor;
|
||||
|
||||
setHovered(cx, cy, u.cursor.event == null);
|
||||
|
||||
if (hoveredAtCursor != null) {
|
||||
if (hoveredAtCursor !== prevHovered) {
|
||||
onHover(hoveredAtCursor.sidx, hoveredAtCursor.didx, hoveredAtCursor);
|
||||
}
|
||||
} else if (prevHovered != null) {
|
||||
onLeave();
|
||||
}
|
||||
}
|
||||
|
||||
return hovered[seriesIdx]?.didx;
|
||||
|
@ -1,9 +1,5 @@
|
||||
import React from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
DashboardCursorSync,
|
||||
FALLBACK_COLOR,
|
||||
Field,
|
||||
FieldColorModeId,
|
||||
@ -34,14 +30,7 @@ import {
|
||||
HideableFieldConfig,
|
||||
MappingType,
|
||||
} from '@grafana/schema';
|
||||
import {
|
||||
FIXED_UNIT,
|
||||
SeriesVisibilityChangeMode,
|
||||
UPlotConfigBuilder,
|
||||
UPlotConfigPrepFn,
|
||||
VizLegendItem,
|
||||
} from '@grafana/ui';
|
||||
import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
|
||||
import { FIXED_UNIT, UPlotConfigBuilder, UPlotConfigPrepFn, VizLegendItem } from '@grafana/ui';
|
||||
import { preparePlotData2, getStackingGroups } from '@grafana/ui/src/components/uPlot/utils';
|
||||
|
||||
import { getConfig, TimelineCoreOptions } from './timeline';
|
||||
@ -53,15 +42,12 @@ interface UPlotConfigOptions {
|
||||
frame: DataFrame;
|
||||
theme: GrafanaTheme2;
|
||||
mode: TimelineMode;
|
||||
sync?: () => DashboardCursorSync;
|
||||
rowHeight?: number;
|
||||
colWidth?: number;
|
||||
showValue: VisibilityMode;
|
||||
alignValue?: TimelineValueAlignment;
|
||||
mergeValues?: boolean;
|
||||
getValueColor: (frameIdx: number, fieldIdx: number, value: unknown) => string;
|
||||
// Identifies the shared key for uPlot cursor sync
|
||||
eventsScope?: string;
|
||||
hoverMulti: boolean;
|
||||
}
|
||||
|
||||
@ -83,27 +69,18 @@ const defaultConfig: PanelFieldConfig = {
|
||||
fillOpacity: 80,
|
||||
};
|
||||
|
||||
export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return SeriesVisibilityChangeMode.AppendToSelection;
|
||||
}
|
||||
return SeriesVisibilityChangeMode.ToggleSelection;
|
||||
}
|
||||
|
||||
export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ({
|
||||
frame,
|
||||
theme,
|
||||
timeZones,
|
||||
getTimeRange,
|
||||
mode,
|
||||
sync,
|
||||
rowHeight,
|
||||
colWidth,
|
||||
showValue,
|
||||
alignValue,
|
||||
mergeValues,
|
||||
getValueColor,
|
||||
eventsScope = '__global_',
|
||||
hoverMulti,
|
||||
}) => {
|
||||
const builder = new UPlotConfigBuilder(timeZones[0]);
|
||||
@ -154,50 +131,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
|
||||
getTimeRange,
|
||||
// hardcoded formatter for state values
|
||||
formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)),
|
||||
onHover: (seriesIndex, valueIndex) => {
|
||||
hoveredSeriesIdx = seriesIndex;
|
||||
hoveredDataIdx = valueIndex;
|
||||
shouldChangeHover = true;
|
||||
},
|
||||
onLeave: () => {
|
||||
hoveredSeriesIdx = null;
|
||||
hoveredDataIdx = null;
|
||||
shouldChangeHover = true;
|
||||
},
|
||||
hoverMulti,
|
||||
};
|
||||
|
||||
let shouldChangeHover = false;
|
||||
let hoveredSeriesIdx: number | null = null;
|
||||
let hoveredDataIdx: number | null = null;
|
||||
|
||||
const coreConfig = getConfig(opts);
|
||||
|
||||
builder.addHook('init', coreConfig.init);
|
||||
builder.addHook('drawClear', coreConfig.drawClear);
|
||||
|
||||
// in TooltipPlugin, this gets invoked and the result is bound to a setCursor hook
|
||||
// which fires after the above setCursor hook, so can take advantage of hoveringOver
|
||||
// already set by the above onHover/onLeave callbacks that fire from coreConfig.setCursor
|
||||
const interpolateTooltip: PlotTooltipInterpolator = (
|
||||
updateActiveSeriesIdx,
|
||||
updateActiveDatapointIdx,
|
||||
updateTooltipPosition
|
||||
) => {
|
||||
if (shouldChangeHover) {
|
||||
if (hoveredSeriesIdx != null) {
|
||||
updateActiveSeriesIdx(hoveredSeriesIdx);
|
||||
updateActiveDatapointIdx(hoveredDataIdx);
|
||||
}
|
||||
|
||||
shouldChangeHover = false;
|
||||
}
|
||||
|
||||
updateTooltipPosition(hoveredSeriesIdx == null);
|
||||
};
|
||||
|
||||
builder.setTooltipInterpolator(interpolateTooltip);
|
||||
|
||||
builder.setPrepData((frames) => preparePlotData2(frames[0], getStackingGroups(frames[0])));
|
||||
|
||||
builder.setCursor(coreConfig.cursor);
|
||||
@ -274,58 +215,9 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
|
||||
});
|
||||
}
|
||||
|
||||
if (sync && sync() !== DashboardCursorSync.Off) {
|
||||
let cursor: Partial<uPlot.Cursor> = {};
|
||||
|
||||
cursor.sync = {
|
||||
key: eventsScope,
|
||||
scales: [xScaleKey, null],
|
||||
};
|
||||
builder.setSync();
|
||||
builder.setCursor(cursor);
|
||||
}
|
||||
|
||||
return builder;
|
||||
};
|
||||
|
||||
export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
|
||||
const names = new Map<string, number>();
|
||||
for (let i = 0; i < frame.fields.length; i++) {
|
||||
names.set(getFieldDisplayName(frame.fields[i], frame), i);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* If any sequential duplicate values exist, this will return a new array
|
||||
* with the future values set to undefined.
|
||||
*
|
||||
* in: 1, 1,undefined, 1,2, 2,null,2,3
|
||||
* out: 1,undefined,undefined,undefined,2,undefined,null,2,3
|
||||
*/
|
||||
export function unsetSameFutureValues(values: unknown[]): unknown[] | undefined {
|
||||
let prevVal = values[0];
|
||||
let clone: unknown[] | undefined = undefined;
|
||||
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
let value = values[i];
|
||||
|
||||
if (value === null) {
|
||||
prevVal = null;
|
||||
} else {
|
||||
if (value === prevVal) {
|
||||
if (!clone) {
|
||||
clone = [...values];
|
||||
}
|
||||
clone[i] = undefined;
|
||||
} else if (value != null) {
|
||||
prevVal = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function getSpanNulls(field: Field) {
|
||||
let spanNulls = field.config.custom?.spanNulls;
|
||||
|
||||
|
@ -1,26 +1,22 @@
|
||||
import { noop } from 'lodash';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { DataFrame, InterpolateFunction, TimeRange } from '@grafana/data';
|
||||
import { VisibilityMode } from '@grafana/schema';
|
||||
import { LegendDisplayMode, UPlotConfigBuilder, useTheme2 } from '@grafana/ui';
|
||||
import { LegendDisplayMode, useTheme2 } from '@grafana/ui';
|
||||
import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart';
|
||||
import { TimelineMode } from 'app/core/components/TimelineChart/utils';
|
||||
|
||||
interface LogTimelineViewerProps {
|
||||
frames: DataFrame[];
|
||||
timeRange: TimeRange;
|
||||
onPointerMove?: (seriesIdx: number, pointerIdx: number) => void;
|
||||
}
|
||||
|
||||
// noop
|
||||
const replaceVariables: InterpolateFunction = (v) => v;
|
||||
|
||||
export const LogTimelineViewer = React.memo(({ frames, timeRange, onPointerMove = noop }: LogTimelineViewerProps) => {
|
||||
export const LogTimelineViewer = React.memo(({ frames, timeRange }: LogTimelineViewerProps) => {
|
||||
const theme = useTheme2();
|
||||
const { setupCursorTracking } = useCursorTimelinePosition(onPointerMove);
|
||||
|
||||
return (
|
||||
<AutoSizer disableHeight>
|
||||
@ -49,61 +45,10 @@ export const LogTimelineViewer = React.memo(({ frames, timeRange, onPointerMove
|
||||
{ label: 'Mixed', color: theme.colors.text.secondary, yAxis: 1 },
|
||||
]}
|
||||
replaceVariables={replaceVariables}
|
||||
>
|
||||
{(builder) => {
|
||||
setupCursorTracking(builder);
|
||||
return null;
|
||||
}}
|
||||
</TimelineChart>
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
});
|
||||
|
||||
function useCursorTimelinePosition(onPointerMove: (seriesIdx: number, pointIdx: number) => void) {
|
||||
const pointerSubject = useRef(
|
||||
new BehaviorSubject<{ seriesIdx: number; pointIdx: number }>({ seriesIdx: 0, pointIdx: 0 })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = pointerSubject.current.subscribe(({ seriesIdx, pointIdx }) => {
|
||||
onPointerMove && onPointerMove(seriesIdx, pointIdx);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [onPointerMove]);
|
||||
|
||||
// Applies cursor tracking to the UPlot chart
|
||||
const setupCursorTracking = (builder: UPlotConfigBuilder) => {
|
||||
builder.setSync();
|
||||
const interpolator = builder.getTooltipInterpolator();
|
||||
|
||||
// I found this in TooltipPlugin.tsx
|
||||
if (interpolator) {
|
||||
builder.addHook('setCursor', (u) => {
|
||||
interpolator(
|
||||
(seriesIdx) => {
|
||||
if (seriesIdx) {
|
||||
const currentPointer = pointerSubject.current.getValue();
|
||||
pointerSubject.current.next({ ...currentPointer, seriesIdx });
|
||||
}
|
||||
},
|
||||
(pointIdx) => {
|
||||
if (pointIdx) {
|
||||
const currentPointer = pointerSubject.current.getValue();
|
||||
pointerSubject.current.next({ ...currentPointer, pointIdx });
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
u
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { setupCursorTracking };
|
||||
}
|
||||
|
||||
LogTimelineViewer.displayName = 'LogTimelineViewer';
|
||||
|
@ -50,7 +50,7 @@ const LokiStateHistory = ({ ruleUID }: Props) => {
|
||||
instancesFilter
|
||||
);
|
||||
|
||||
const { frameSubset, frameSubsetTimestamps, frameTimeRange } = useFrameSubset(dataFrames);
|
||||
const { frameSubset, frameTimeRange } = useFrameSubset(dataFrames);
|
||||
|
||||
const onLogRecordLabelClick = useCallback(
|
||||
(label: string) => {
|
||||
@ -66,26 +66,6 @@ const LokiStateHistory = ({ ruleUID }: Props) => {
|
||||
setValue('query', '');
|
||||
}, [setInstancesFilter, setValue]);
|
||||
|
||||
const refToHighlight = useRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
const onTimelinePointerMove = useCallback(
|
||||
(seriesIdx: number, pointIdx: number) => {
|
||||
// remove the highlight from the previous refToHighlight
|
||||
refToHighlight.current?.classList.remove(styles.highlightedLogRecord);
|
||||
|
||||
const timestamp = frameSubsetTimestamps[pointIdx];
|
||||
const newTimestampRef = logsRef.current.get(timestamp);
|
||||
|
||||
// now we have the new ref, add the styles
|
||||
newTimestampRef?.classList.add(styles.highlightedLogRecord);
|
||||
// keeping this here (commented) in case we decide we want to go back to this
|
||||
// newTimestampRef?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
refToHighlight.current = newTimestampRef;
|
||||
},
|
||||
[frameSubsetTimestamps, styles.highlightedLogRecord]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
@ -138,7 +118,7 @@ const LokiStateHistory = ({ ruleUID }: Props) => {
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.graphWrapper}>
|
||||
<LogTimelineViewer frames={frameSubset} timeRange={frameTimeRange} onPointerMove={onTimelinePointerMove} />
|
||||
<LogTimelineViewer frames={frameSubset} timeRange={frameTimeRange} />
|
||||
</div>
|
||||
{hasMoreInstances && (
|
||||
<div className={styles.moreInstancesWarning}>
|
||||
|
@ -1,25 +1,20 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
|
||||
import {
|
||||
CartesianCoords2D,
|
||||
compareDataFrameStructures,
|
||||
DataFrame,
|
||||
Field,
|
||||
FieldColorModeId,
|
||||
FieldType,
|
||||
getFieldDisplayName,
|
||||
PanelProps,
|
||||
TimeRange,
|
||||
VizOrientation,
|
||||
} from '@grafana/data';
|
||||
import { PanelDataErrorView, config } from '@grafana/runtime';
|
||||
import { SortOrder } from '@grafana/schema';
|
||||
import { PanelDataErrorView } from '@grafana/runtime';
|
||||
import {
|
||||
GraphGradientMode,
|
||||
measureText,
|
||||
PlotLegend,
|
||||
Portal,
|
||||
StackingMode,
|
||||
TooltipDisplayMode,
|
||||
UPlotConfigBuilder,
|
||||
UPLOT_AXIS_FONT_SIZE,
|
||||
@ -27,23 +22,17 @@ import {
|
||||
useTheme2,
|
||||
VizLayout,
|
||||
VizLegend,
|
||||
VizTooltipContainer,
|
||||
TooltipPlugin2,
|
||||
} from '@grafana/ui';
|
||||
import { HoverEvent, addTooltipSupport } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport';
|
||||
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
|
||||
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
|
||||
import { GraphNG, GraphNGProps, PropDiffFn } from 'app/core/components/GraphNG/GraphNG';
|
||||
import { getFieldLegendItem } from 'app/core/components/TimelineChart/utils';
|
||||
import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView';
|
||||
|
||||
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
import { prepareBarChartDisplayValues, preparePlotConfigBuilder } from './utils';
|
||||
|
||||
const TOOLTIP_OFFSET = 10;
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
@ -73,29 +62,6 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
|
||||
const theme = useTheme2();
|
||||
const { dataLinkPostProcessor } = usePanelContext();
|
||||
|
||||
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
|
||||
const isToolTipOpen = useRef<boolean>(false);
|
||||
|
||||
const [hover, setHover] = useState<HoverEvent | undefined>(undefined);
|
||||
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null);
|
||||
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||
const [isActive, setIsActive] = useState<boolean>(false);
|
||||
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
|
||||
|
||||
const onCloseToolTip = () => {
|
||||
isToolTipOpen.current = false;
|
||||
setCoords(null);
|
||||
setShouldDisplayCloseButton(false);
|
||||
};
|
||||
|
||||
const onUPlotClick = () => {
|
||||
isToolTipOpen.current = !isToolTipOpen.current;
|
||||
|
||||
// Linking into useState required to re-render tooltip
|
||||
setShouldDisplayCloseButton(isToolTipOpen.current);
|
||||
};
|
||||
|
||||
const frame0Ref = useRef<DataFrame>();
|
||||
const colorByFieldRef = useRef<Field>();
|
||||
|
||||
@ -157,51 +123,9 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
|
||||
);
|
||||
}
|
||||
|
||||
const renderTooltip = (alignedFrame: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => {
|
||||
const field = seriesIdx == null ? null : alignedFrame.fields[seriesIdx];
|
||||
if (field) {
|
||||
const disp = getFieldDisplayName(field, alignedFrame);
|
||||
seriesIdx = info.aligned.fields.findIndex((f) => disp === getFieldDisplayName(f, info.aligned));
|
||||
}
|
||||
const tooltipMode =
|
||||
options.fullHighlight && options.stacking !== StackingMode.None ? TooltipDisplayMode.Multi : options.tooltip.mode;
|
||||
|
||||
const tooltipSort = options.tooltip.mode === TooltipDisplayMode.Multi ? options.tooltip.sort : SortOrder.None;
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayCloseButton && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CloseButton
|
||||
onClick={onCloseToolTip}
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DataHoverView
|
||||
data={info.aligned}
|
||||
rowIndex={datapointIdx}
|
||||
columnIndex={seriesIdx}
|
||||
sortOrder={tooltipSort}
|
||||
mode={tooltipMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLegend = (config: UPlotConfigBuilder) => {
|
||||
const { legend } = options;
|
||||
|
||||
if (!config || legend.showLegend === false) {
|
||||
return null;
|
||||
}
|
||||
@ -309,8 +233,6 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
|
||||
});
|
||||
};
|
||||
|
||||
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
|
||||
|
||||
return (
|
||||
<GraphNG
|
||||
theme={theme}
|
||||
@ -329,7 +251,7 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
|
||||
dataLinkPostProcessor={dataLinkPostProcessor}
|
||||
>
|
||||
{(config) => {
|
||||
if (showNewVizTooltips && options.tooltip.mode !== TooltipDisplayMode.None) {
|
||||
if (options.tooltip.mode !== TooltipDisplayMode.None) {
|
||||
return (
|
||||
<TooltipPlugin2
|
||||
config={config}
|
||||
@ -350,42 +272,11 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!showNewVizTooltips && oldConfig.current !== config) {
|
||||
oldConfig.current = addTooltipSupport({
|
||||
config,
|
||||
onUPlotClick,
|
||||
setFocusedSeriesIdx,
|
||||
setFocusedPointIdx,
|
||||
setCoords,
|
||||
setHover,
|
||||
isToolTipOpen,
|
||||
isActive,
|
||||
setIsActive,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.tooltip.mode === TooltipDisplayMode.None) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
{hover && coords && focusedSeriesIdx && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: coords.viewport.x, y: coords.viewport.y }}
|
||||
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{renderTooltip(info.viz[0], focusedSeriesIdx, focusedPointIdx)}
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
return null;
|
||||
}}
|
||||
</GraphNG>
|
||||
);
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
VizTextDisplayOptions,
|
||||
VizLegendOptions,
|
||||
} from '@grafana/schema';
|
||||
import { measureText, PlotTooltipInterpolator } from '@grafana/ui';
|
||||
import { measureText } from '@grafana/ui';
|
||||
import { timeUnitSize } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
|
||||
import { StackingGroup, preparePlotData2 } from '@grafana/ui/src/components/uPlot/utils';
|
||||
|
||||
@ -56,8 +56,6 @@ export interface BarsOptions {
|
||||
formatShortValue: (seriesIdx: number, value: unknown) => string;
|
||||
timeZone?: TimeZone;
|
||||
text?: VizTextDisplayOptions;
|
||||
onHover?: (seriesIdx: number, valueIdx: number) => void;
|
||||
onLeave?: (seriesIdx: number, valueIdx: number) => void;
|
||||
hoverMulti?: boolean;
|
||||
legend?: VizLegendOptions;
|
||||
xSpacing?: number;
|
||||
@ -634,22 +632,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
|
||||
u.ctx.restore();
|
||||
};
|
||||
|
||||
// handle hover interaction with quadtree probing
|
||||
const interpolateTooltip: PlotTooltipInterpolator = (
|
||||
updateActiveSeriesIdx,
|
||||
updateActiveDatapointIdx,
|
||||
updateTooltipPosition,
|
||||
u
|
||||
) => {
|
||||
if (hRect) {
|
||||
updateActiveSeriesIdx(hRect.sidx);
|
||||
updateActiveDatapointIdx(hRect.didx);
|
||||
updateTooltipPosition();
|
||||
} else {
|
||||
updateTooltipPosition(true);
|
||||
}
|
||||
};
|
||||
|
||||
let alignedTotals: AlignedData | null = null;
|
||||
|
||||
function prepData(frames: DataFrame[], stackingGroups: StackingGroup[]) {
|
||||
@ -673,7 +655,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
|
||||
init,
|
||||
drawClear,
|
||||
draw,
|
||||
interpolateTooltip,
|
||||
prepData,
|
||||
};
|
||||
}
|
||||
|
@ -17,7 +17,6 @@ import {
|
||||
getFieldDisplayName,
|
||||
} from '@grafana/data';
|
||||
import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames';
|
||||
import { config as runtimeConfig } from '@grafana/runtime';
|
||||
import {
|
||||
AxisColorMode,
|
||||
AxisPlacement,
|
||||
@ -138,9 +137,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
|
||||
builder.addHook('drawClear', config.drawClear);
|
||||
builder.addHook('draw', config.draw);
|
||||
|
||||
const showNewVizTooltips = Boolean(runtimeConfig.featureToggles.newVizTooltips);
|
||||
!showNewVizTooltips && builder.setTooltipInterpolator(config.interpolateTooltip);
|
||||
|
||||
if (xTickLabelRotation !== 0) {
|
||||
// these are the amount of space we already have available between plot edge and first label
|
||||
// TODO: removing these hardcoded value requires reading back uplot instance props
|
||||
|
@ -1,7 +1,7 @@
|
||||
// this file is pretty much a copy-paste of TimeSeriesPanel.tsx :(
|
||||
// with some extra renderers passed to the <TimeSeries> component
|
||||
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { Field, getDisplayProcessor, PanelProps } from '@grafana/data';
|
||||
@ -10,12 +10,10 @@ import { DashboardCursorSync, TooltipDisplayMode } from '@grafana/schema';
|
||||
import {
|
||||
EventBusPlugin,
|
||||
KeyboardPlugin,
|
||||
TooltipPlugin,
|
||||
TooltipPlugin2,
|
||||
UPlotConfigBuilder,
|
||||
usePanelContext,
|
||||
useTheme2,
|
||||
ZoomPlugin,
|
||||
} from '@grafana/ui';
|
||||
import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
|
||||
import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder';
|
||||
@ -24,10 +22,7 @@ import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
|
||||
import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin';
|
||||
import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin';
|
||||
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
|
||||
import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin';
|
||||
import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin';
|
||||
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
|
||||
import { ThresholdControlsPlugin } from '../timeseries/plugins/ThresholdControlsPlugin';
|
||||
@ -53,6 +48,7 @@ export const CandlestickPanel = ({
|
||||
}: CandlestickPanelProps) => {
|
||||
const {
|
||||
sync,
|
||||
eventsScope,
|
||||
canAddAnnotations,
|
||||
onThresholdsChange,
|
||||
canEditThresholds,
|
||||
@ -63,25 +59,13 @@ export const CandlestickPanel = ({
|
||||
|
||||
const theme = useTheme2();
|
||||
|
||||
// TODO: we should just re-init when this changes, and have this be a static setting
|
||||
const syncTooltip = useCallback(
|
||||
() => sync?.() === DashboardCursorSync.Tooltip,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const syncAny = useCallback(
|
||||
() => sync?.() !== DashboardCursorSync.Off,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const info = useMemo(() => {
|
||||
return prepareCandlestickFields(data.series, options, theme, timeRange);
|
||||
}, [data.series, options, theme, timeRange]);
|
||||
|
||||
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
|
||||
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
|
||||
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
|
||||
|
||||
const { renderers, tweakScale, tweakAxis, shouldRenderPrice } = useMemo(() => {
|
||||
let tweakScale = (opts: ScaleProps, forField: Field) => opts;
|
||||
@ -264,7 +248,6 @@ export const CandlestickPanel = ({
|
||||
}
|
||||
|
||||
const enableAnnotationCreation = Boolean(canAddAnnotations?.());
|
||||
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
|
||||
|
||||
return (
|
||||
<TimeSeries
|
||||
@ -281,13 +264,16 @@ export const CandlestickPanel = ({
|
||||
options={options}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinkPostProcessor={dataLinkPostProcessor}
|
||||
cursorSync={cursorSync}
|
||||
>
|
||||
{(uplotConfig, alignedFrame) => {
|
||||
return (
|
||||
<>
|
||||
<KeyboardPlugin config={uplotConfig} />
|
||||
<EventBusPlugin config={uplotConfig} sync={syncAny} eventBus={eventBus} frame={alignedFrame} />
|
||||
{showNewVizTooltips ? (
|
||||
{cursorSync !== DashboardCursorSync.Off && (
|
||||
<EventBusPlugin config={uplotConfig} eventBus={eventBus} frame={alignedFrame} />
|
||||
)}
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={uplotConfig}
|
||||
hoverMode={
|
||||
@ -295,7 +281,8 @@ export const CandlestickPanel = ({
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
clientZoom={true}
|
||||
syncTooltip={syncTooltip}
|
||||
syncMode={cursorSync}
|
||||
syncScope={eventsScope}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
@ -321,88 +308,24 @@ export const CandlestickPanel = ({
|
||||
isPinned={isPinned}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
scrollable={isTooltipScrollable(options.tooltip)}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ZoomPlugin config={uplotConfig} onZoom={onChangeTimeRange} withZoomY={true} />
|
||||
<TooltipPlugin
|
||||
data={alignedFrame}
|
||||
config={uplotConfig}
|
||||
mode={TooltipDisplayMode.Multi}
|
||||
sync={sync}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Renders annotation markers*/}
|
||||
{showNewVizTooltips ? (
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={uplotConfig}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
/>
|
||||
) : (
|
||||
data.annotations && (
|
||||
<AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} />
|
||||
)
|
||||
)}
|
||||
{/* Enables annotations creation*/}
|
||||
{!showNewVizTooltips ? (
|
||||
enableAnnotationCreation ? (
|
||||
<AnnotationEditorPlugin data={alignedFrame} timeZone={timeZone} config={uplotConfig}>
|
||||
{({ startAnnotating }) => {
|
||||
return (
|
||||
<ContextMenuPlugin
|
||||
data={alignedFrame}
|
||||
config={uplotConfig}
|
||||
timeZone={timeZone}
|
||||
replaceVariables={replaceVariables}
|
||||
defaultItems={
|
||||
enableAnnotationCreation
|
||||
? [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
label: 'Add annotation',
|
||||
ariaLabel: 'Add annotation',
|
||||
icon: 'comment-alt',
|
||||
onClick: (e, p) => {
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
startAnnotating({ coords: p.coords });
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</AnnotationEditorPlugin>
|
||||
) : (
|
||||
<ContextMenuPlugin
|
||||
data={alignedFrame}
|
||||
config={uplotConfig}
|
||||
timeZone={timeZone}
|
||||
replaceVariables={replaceVariables}
|
||||
defaultItems={[]}
|
||||
/>
|
||||
)
|
||||
) : undefined}
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={uplotConfig}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
/>
|
||||
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
|
||||
{data.annotations && (
|
||||
<ExemplarsPlugin config={uplotConfig} exemplars={data.annotations} timeZone={timeZone} />
|
||||
)}
|
||||
|
||||
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
|
||||
<ThresholdControlsPlugin
|
||||
config={uplotConfig}
|
||||
@ -410,8 +333,6 @@ export const CandlestickPanel = ({
|
||||
onThresholdsChange={canEditThresholds ? onThresholdsChange : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
@ -1,222 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DataFrameType,
|
||||
Field,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getFieldDisplayName,
|
||||
LinkModel,
|
||||
TimeRange,
|
||||
InterpolateFunction,
|
||||
} from '@grafana/data';
|
||||
import { HeatmapCellLayout } from '@grafana/schema';
|
||||
import { LinkButton, VerticalGroup } from '@grafana/ui';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
|
||||
import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView';
|
||||
|
||||
import { getDataLinks } from '../status-history/utils';
|
||||
|
||||
import { HeatmapData } from './fields';
|
||||
import { renderHistogram } from './renderHistogram';
|
||||
import { HeatmapHoverEvent } from './utils';
|
||||
|
||||
type Props = {
|
||||
data: HeatmapData;
|
||||
hover: HeatmapHoverEvent;
|
||||
showHistogram?: boolean;
|
||||
timeRange: TimeRange;
|
||||
replaceVars: InterpolateFunction;
|
||||
};
|
||||
|
||||
export const HeatmapHoverView = (props: Props) => {
|
||||
if (props.hover.seriesIdx === 2) {
|
||||
return <DataHoverView data={props.data.exemplars} rowIndex={props.hover.dataIdx} header={'Exemplar'} />;
|
||||
}
|
||||
return <HeatmapHoverCell {...props} />;
|
||||
};
|
||||
|
||||
const HeatmapHoverCell = ({ data, hover, showHistogram = false }: Props) => {
|
||||
const index = hover.dataIdx;
|
||||
|
||||
const [isSparse] = useState(
|
||||
() => data.heatmap?.meta?.type === DataFrameType.HeatmapCells && !isHeatmapCellsDense(data.heatmap)
|
||||
);
|
||||
|
||||
const xField = data.heatmap?.fields[0];
|
||||
const yField = data.heatmap?.fields[1];
|
||||
const countField = data.heatmap?.fields[2];
|
||||
|
||||
const xDisp = (v: number) => {
|
||||
if (xField?.display) {
|
||||
return formattedValueToString(xField.display(v));
|
||||
}
|
||||
if (xField?.type === FieldType.time) {
|
||||
const tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
|
||||
const dashboard = getDashboardSrv().getCurrent();
|
||||
return dashboard?.formatDate(v, tooltipTimeFormat);
|
||||
}
|
||||
return `${v}`;
|
||||
};
|
||||
|
||||
const xVals = xField?.values;
|
||||
const yVals = yField?.values;
|
||||
const countVals = countField?.values;
|
||||
|
||||
// labeled buckets
|
||||
const meta = readHeatmapRowsCustomMeta(data.heatmap);
|
||||
const yDisp = yField?.display ? (v: string) => formattedValueToString(yField.display!(v)) : (v: string) => `${v}`;
|
||||
|
||||
const yValueIdx = index % (data.yBucketCount ?? 1);
|
||||
const xValueIdx = Math.floor(index / (data.yBucketCount ?? 1));
|
||||
|
||||
let yBucketMin: string;
|
||||
let yBucketMax: string;
|
||||
|
||||
let nonNumericOrdinalDisplay: string | undefined = undefined;
|
||||
|
||||
if (meta.yOrdinalDisplay) {
|
||||
const yMinIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx - 1 : yValueIdx;
|
||||
const yMaxIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx : yValueIdx + 1;
|
||||
yBucketMin = yMinIdx < 0 ? meta.yMinDisplay! : `${meta.yOrdinalDisplay[yMinIdx]}`;
|
||||
yBucketMax = `${meta.yOrdinalDisplay[yMaxIdx]}`;
|
||||
|
||||
// e.g. "pod-xyz123"
|
||||
if (!meta.yOrdinalLabel || Number.isNaN(+meta.yOrdinalLabel[0])) {
|
||||
nonNumericOrdinalDisplay = data.yLayout === HeatmapCellLayout.le ? yBucketMax : yBucketMin;
|
||||
}
|
||||
} else {
|
||||
const value = yVals?.[yValueIdx];
|
||||
|
||||
if (data.yLayout === HeatmapCellLayout.le) {
|
||||
yBucketMax = `${value}`;
|
||||
|
||||
if (data.yLog) {
|
||||
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
|
||||
let exp = logFn(value) - 1 / data.yLogSplit!;
|
||||
yBucketMin = `${data.yLog ** exp}`;
|
||||
} else {
|
||||
yBucketMin = `${value - data.yBucketSize!}`;
|
||||
}
|
||||
} else {
|
||||
yBucketMin = `${value}`;
|
||||
|
||||
if (data.yLog) {
|
||||
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
|
||||
let exp = logFn(value) + 1 / data.yLogSplit!;
|
||||
yBucketMax = `${data.yLog ** exp}`;
|
||||
} else {
|
||||
yBucketMax = `${value + data.yBucketSize!}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let xBucketMin: number;
|
||||
let xBucketMax: number;
|
||||
|
||||
if (data.xLayout === HeatmapCellLayout.le) {
|
||||
xBucketMax = xVals?.[index];
|
||||
xBucketMin = xBucketMax - data.xBucketSize!;
|
||||
} else {
|
||||
xBucketMin = xVals?.[index];
|
||||
xBucketMax = xBucketMin + data.xBucketSize!;
|
||||
}
|
||||
|
||||
const count = countVals?.[index];
|
||||
|
||||
let links: Array<LinkModel<Field>> = [];
|
||||
|
||||
const linksField = data.series?.fields[yValueIdx + 1];
|
||||
|
||||
if (linksField != null) {
|
||||
const visible = !Boolean(linksField.config.custom?.hideFrom?.tooltip);
|
||||
const hasLinks = (linksField.config.links?.length ?? 0) > 0;
|
||||
|
||||
if (visible && hasLinks) {
|
||||
links = getDataLinks(linksField, xValueIdx);
|
||||
}
|
||||
}
|
||||
|
||||
let can = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
let histCssWidth = 264;
|
||||
let histCssHeight = 64;
|
||||
let histCanWidth = Math.round(histCssWidth * uPlot.pxRatio);
|
||||
let histCanHeight = Math.round(histCssHeight * uPlot.pxRatio);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (showHistogram && xVals != null && countVals != null) {
|
||||
renderHistogram(can, histCanWidth, histCanHeight, xVals, countVals, index, data.yBucketCount!);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[index]
|
||||
);
|
||||
|
||||
if (isSparse) {
|
||||
return (
|
||||
<div>
|
||||
<DataHoverView data={data.heatmap} rowIndex={index} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderYBucket = () => {
|
||||
if (nonNumericOrdinalDisplay) {
|
||||
return <div>Name: {nonNumericOrdinalDisplay}</div>;
|
||||
}
|
||||
|
||||
switch (data.yLayout) {
|
||||
case HeatmapCellLayout.unknown:
|
||||
return <div>{yDisp(yBucketMin)}</div>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div>{xDisp(xBucketMin)}</div>
|
||||
{data.xLayout !== HeatmapCellLayout.unknown && <div>{xDisp(xBucketMax)}</div>}
|
||||
</div>
|
||||
{showHistogram && (
|
||||
<canvas
|
||||
width={histCanWidth}
|
||||
height={histCanHeight}
|
||||
ref={can}
|
||||
style={{ width: histCssWidth + 'px', height: histCssHeight + 'px' }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{renderYBucket()}
|
||||
<div>
|
||||
{getFieldDisplayName(countField!, data.heatmap)}: {data.display!(count)}
|
||||
</div>
|
||||
</div>
|
||||
{links.length > 0 && (
|
||||
<VerticalGroup>
|
||||
{links.map((link, i) => (
|
||||
<LinkButton
|
||||
key={i}
|
||||
icon={'external-link-alt'}
|
||||
target={link.target}
|
||||
href={link.href}
|
||||
onClick={link.onClick}
|
||||
fill="text"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{link.title}
|
||||
</LinkButton>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,36 +1,33 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { DashboardCursorSync, DataFrameType, GrafanaTheme2, PanelProps, TimeRange } from '@grafana/data';
|
||||
import { config, PanelDataErrorView } from '@grafana/runtime';
|
||||
import { DashboardCursorSync, PanelProps, TimeRange } from '@grafana/data';
|
||||
import { PanelDataErrorView } from '@grafana/runtime';
|
||||
import { ScaleDistributionConfig } from '@grafana/schema';
|
||||
import {
|
||||
Portal,
|
||||
ScaleDistribution,
|
||||
TooltipPlugin2,
|
||||
TooltipDisplayMode,
|
||||
ZoomPlugin,
|
||||
UPlotChart,
|
||||
usePanelContext,
|
||||
useStyles2,
|
||||
useTheme2,
|
||||
VizLayout,
|
||||
VizTooltipContainer,
|
||||
EventBusPlugin,
|
||||
} from '@grafana/ui';
|
||||
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
|
||||
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
|
||||
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
|
||||
import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
|
||||
|
||||
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
|
||||
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
|
||||
import { isTooltipScrollable } from '../timeseries/utils';
|
||||
|
||||
import { ExemplarModalHeader } from './ExemplarModalHeader';
|
||||
import { HeatmapHoverView } from './HeatmapHoverViewOld';
|
||||
import { HeatmapTooltip } from './HeatmapTooltip';
|
||||
import { prepareHeatmapData } from './fields';
|
||||
import { quantizeScheme } from './palettes';
|
||||
import { Options } from './types';
|
||||
import { HeatmapHoverEvent, prepConfig } from './utils';
|
||||
import { prepConfig } from './utils';
|
||||
|
||||
interface HeatmapPanelProps extends PanelProps<Options> {}
|
||||
|
||||
@ -49,20 +46,8 @@ export const HeatmapPanel = ({
|
||||
}: HeatmapPanelProps) => {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles);
|
||||
const { sync, canAddAnnotations } = usePanelContext();
|
||||
|
||||
// TODO: we should just re-init when this changes, and have this be a static setting
|
||||
const syncTooltip = useCallback(
|
||||
() => sync?.() === DashboardCursorSync.Tooltip,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const syncAny = useCallback(
|
||||
() => sync?.() !== DashboardCursorSync.Off,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
const { sync, eventsScope, canAddAnnotations } = usePanelContext();
|
||||
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
|
||||
|
||||
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
|
||||
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
|
||||
@ -111,35 +96,9 @@ export const HeatmapPanel = ({
|
||||
return [null, info.heatmap?.fields.map((f) => f.values), [exemplarsXFacet, exemplarsYFacet]];
|
||||
}, [info.heatmap, info.exemplars]);
|
||||
|
||||
const [hover, setHover] = useState<HeatmapHoverEvent | undefined>(undefined);
|
||||
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
|
||||
const isToolTipOpen = useRef<boolean>(false);
|
||||
|
||||
const onCloseToolTip = () => {
|
||||
isToolTipOpen.current = false;
|
||||
setShouldDisplayCloseButton(false);
|
||||
onhover(null);
|
||||
};
|
||||
|
||||
const onclick = () => {
|
||||
isToolTipOpen.current = !isToolTipOpen.current;
|
||||
|
||||
// Linking into useState required to re-render tooltip
|
||||
setShouldDisplayCloseButton(isToolTipOpen.current);
|
||||
};
|
||||
|
||||
const onhover = useCallback(
|
||||
(evt?: HeatmapHoverEvent | null) => {
|
||||
setHover(evt ?? undefined);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[options, data.structureRev]
|
||||
);
|
||||
|
||||
// ugh
|
||||
const dataRef = useRef(info);
|
||||
dataRef.current = info;
|
||||
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
|
||||
|
||||
const builder = useMemo(() => {
|
||||
const scaleConfig: ScaleDistributionConfig = dataRef.current?.heatmap?.fields[1].config?.custom?.scaleDistribution;
|
||||
@ -147,12 +106,8 @@ export const HeatmapPanel = ({
|
||||
return prepConfig({
|
||||
dataRef,
|
||||
theme,
|
||||
onhover: !showNewVizTooltips ? onhover : null,
|
||||
onclick: !showNewVizTooltips && options.tooltip.mode !== TooltipDisplayMode.None ? onclick : null,
|
||||
isToolTipOpen,
|
||||
timeZone,
|
||||
getTimeRange: () => timeRangeRef.current,
|
||||
sync,
|
||||
cellGap: options.cellGap,
|
||||
hideLE: options.filterValues?.le,
|
||||
hideGE: options.filterValues?.ge,
|
||||
@ -160,24 +115,26 @@ export const HeatmapPanel = ({
|
||||
yAxisConfig: options.yAxis,
|
||||
ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options, timeZone, data.structureRev]);
|
||||
}, [options, timeZone, data.structureRev, cursorSync]);
|
||||
|
||||
const renderLegend = () => {
|
||||
if (!info.heatmap || !options.legend.show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let heatmapType = dataRef.current?.heatmap?.meta?.type;
|
||||
let isSparseHeatmap = heatmapType === DataFrameType.HeatmapCells && !isHeatmapCellsDense(dataRef.current?.heatmap!);
|
||||
let countFieldIdx = !isSparseHeatmap ? 2 : 3;
|
||||
const countField = info.heatmap.fields[countFieldIdx];
|
||||
|
||||
let hoverValue: number | undefined = undefined;
|
||||
|
||||
// let heatmapType = dataRef.current?.heatmap?.meta?.type;
|
||||
// let isSparseHeatmap = heatmapType === DataFrameType.HeatmapCells && !isHeatmapCellsDense(dataRef.current?.heatmap!);
|
||||
// let countFieldIdx = !isSparseHeatmap ? 2 : 3;
|
||||
// const countField = info.heatmap.fields[countFieldIdx];
|
||||
|
||||
// seriesIdx: 1 is heatmap layer; 2 is exemplar layer
|
||||
if (hover && info.heatmap.fields && hover.seriesIdx === 1) {
|
||||
hoverValue = countField.values[hover.dataIdx];
|
||||
}
|
||||
// if (hover && info.heatmap.fields && hover.seriesIdx === 1) {
|
||||
// hoverValue = countField.values[hover.dataIdx];
|
||||
// }
|
||||
|
||||
return (
|
||||
<VizLayout.Legend placement="bottom" maxHeight="20%">
|
||||
@ -212,90 +169,70 @@ export const HeatmapPanel = ({
|
||||
<>
|
||||
<VizLayout width={width} height={height} legend={renderLegend()}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
<UPlotChart config={builder} data={facets as any} width={vizWidth} height={vizHeight}>
|
||||
<EventBusPlugin config={builder} sync={syncAny} eventBus={eventBus} frame={info.series ?? info.heatmap} />
|
||||
{!showNewVizTooltips && <ZoomPlugin config={builder} onZoom={onChangeTimeRange} />}
|
||||
{showNewVizTooltips && (
|
||||
<>
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={builder}
|
||||
hoverMode={TooltipHoverMode.xyOne}
|
||||
queryZoom={onChangeTimeRange}
|
||||
syncTooltip={syncTooltip}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<HeatmapTooltip
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
dataRef={dataRef}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
showHistogram={options.tooltip.yHistogram}
|
||||
showColorScale={options.tooltip.showColorScale}
|
||||
panelData={data}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
)}
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={builder}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
canvasRegionRendering={false}
|
||||
/>
|
||||
</>
|
||||
<UPlotChart key={builder.uid} config={builder} data={facets as any} width={vizWidth} height={vizHeight}>
|
||||
{cursorSync !== DashboardCursorSync.Off && (
|
||||
<EventBusPlugin config={builder} eventBus={eventBus} frame={info.series ?? info.heatmap} />
|
||||
)}
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={builder}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
syncMode={cursorSync}
|
||||
syncScope={eventsScope}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<HeatmapTooltip
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
dataRef={dataRef}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
showHistogram={options.tooltip.yHistogram}
|
||||
showColorScale={options.tooltip.showColorScale}
|
||||
panelData={data}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
scrollable={isTooltipScrollable(options.tooltip)}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
/>
|
||||
)}
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={builder}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
canvasRegionRendering={false}
|
||||
/>
|
||||
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
|
||||
</UPlotChart>
|
||||
)}
|
||||
</VizLayout>
|
||||
{!showNewVizTooltips && (
|
||||
<>
|
||||
<Portal>
|
||||
{hover && options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: hover.pageX, y: hover.pageY }}
|
||||
offset={{ x: 10, y: 10 }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{shouldDisplayCloseButton && <ExemplarModalHeader onClick={onCloseToolTip} />}
|
||||
<HeatmapHoverView
|
||||
timeRange={timeRange}
|
||||
data={info}
|
||||
hover={hover}
|
||||
showHistogram={options.tooltip.yHistogram}
|
||||
replaceVars={replaceVariables}
|
||||
/>
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
const getStyles = () => ({
|
||||
colorScaleWrapper: css({
|
||||
marginLeft: '25px',
|
||||
padding: '10px 0',
|
||||
|
@ -39,6 +39,8 @@ interface HeatmapTooltipProps {
|
||||
dismiss: () => void;
|
||||
panelData: PanelData;
|
||||
annotate?: () => void;
|
||||
scrollable?: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const HeatmapTooltip = (props: HeatmapTooltipProps) => {
|
||||
@ -64,6 +66,8 @@ const HeatmapHoverCell = ({
|
||||
showColorScale = false,
|
||||
mode,
|
||||
annotate,
|
||||
scrollable,
|
||||
maxHeight,
|
||||
}: HeatmapTooltipProps) => {
|
||||
const index = dataIdxs[1]!;
|
||||
const data = dataRef.current;
|
||||
@ -349,7 +353,7 @@ const HeatmapHoverCell = ({
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<VizTooltipHeader item={headerItem} isPinned={isPinned} />
|
||||
<VizTooltipContent items={contentItems} isPinned={isPinned}>
|
||||
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} maxHeight={maxHeight}>
|
||||
{customContent?.map((content, i) => (
|
||||
<div key={i} style={{ padding: `${theme.spacing(1)} 0` }}>
|
||||
{content}
|
||||
|
@ -428,11 +428,11 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
|
||||
path: 'tooltip.maxHeight',
|
||||
name: 'Max height',
|
||||
category,
|
||||
defaultValue: 600,
|
||||
defaultValue: undefined,
|
||||
settings: {
|
||||
integer: true,
|
||||
},
|
||||
showIf: (options) => false, // config.featureToggles.newVizTooltips && options.tooltip?.mode !== TooltipDisplayMode.None,
|
||||
showIf: (options) => options.tooltip?.mode !== TooltipDisplayMode.None,
|
||||
});
|
||||
|
||||
category = ['Legend'];
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { MutableRefObject, RefObject } from 'react';
|
||||
import { RefObject } from 'react';
|
||||
import uPlot, { Cursor } from 'uplot';
|
||||
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
DataFrameType,
|
||||
formattedValueToString,
|
||||
getValueFormat,
|
||||
@ -41,25 +40,9 @@ interface PointsBuilderOpts {
|
||||
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
|
||||
}
|
||||
|
||||
export interface HeatmapHoverEvent {
|
||||
seriesIdx: number;
|
||||
dataIdx: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}
|
||||
|
||||
export interface HeatmapZoomEvent {
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
}
|
||||
|
||||
interface PrepConfigOpts {
|
||||
dataRef: RefObject<HeatmapData>;
|
||||
theme: GrafanaTheme2;
|
||||
onhover?: null | ((evt?: HeatmapHoverEvent | null) => void);
|
||||
onclick?: null | ((evt?: Object) => void);
|
||||
onzoom?: null | ((evt: HeatmapZoomEvent) => void);
|
||||
isToolTipOpen?: MutableRefObject<boolean>;
|
||||
timeZone: string;
|
||||
getTimeRange: () => TimeRange;
|
||||
exemplarColor: string;
|
||||
@ -68,28 +51,10 @@ interface PrepConfigOpts {
|
||||
hideGE?: number;
|
||||
yAxisConfig: YAxisConfig;
|
||||
ySizeDivisor?: number;
|
||||
sync?: () => DashboardCursorSync;
|
||||
// Identifies the shared key for uPlot cursor sync
|
||||
eventsScope?: string;
|
||||
}
|
||||
|
||||
export function prepConfig(opts: PrepConfigOpts) {
|
||||
const {
|
||||
dataRef,
|
||||
theme,
|
||||
onhover,
|
||||
onclick,
|
||||
isToolTipOpen,
|
||||
timeZone,
|
||||
getTimeRange,
|
||||
cellGap,
|
||||
hideLE,
|
||||
hideGE,
|
||||
yAxisConfig,
|
||||
ySizeDivisor,
|
||||
sync,
|
||||
eventsScope = '__global_',
|
||||
} = opts;
|
||||
const { dataRef, theme, timeZone, getTimeRange, cellGap, hideLE, hideGE, yAxisConfig, ySizeDivisor } = opts;
|
||||
|
||||
const xScaleKey = 'x';
|
||||
let isTime = true;
|
||||
@ -108,8 +73,6 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
|
||||
let builder = new UPlotConfigBuilder(timeZone);
|
||||
|
||||
let rect: DOMRect;
|
||||
|
||||
builder.addHook('init', (u) => {
|
||||
u.root.querySelectorAll<HTMLElement>('.u-cursor-pt').forEach((el) => {
|
||||
Object.assign(el.style, {
|
||||
@ -118,20 +81,6 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
background: 'transparent',
|
||||
});
|
||||
});
|
||||
|
||||
onclick &&
|
||||
u.over.addEventListener(
|
||||
'mouseup',
|
||||
(e) => {
|
||||
// @ts-ignore
|
||||
let isDragging: boolean = u.cursor.drag._x || u.cursor.drag._y;
|
||||
|
||||
if (!isDragging) {
|
||||
onclick(e);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
if (isTime) {
|
||||
@ -153,48 +102,6 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
});
|
||||
}
|
||||
|
||||
// rect of .u-over (grid area)
|
||||
builder.addHook('syncRect', (u, r) => {
|
||||
rect = r;
|
||||
});
|
||||
|
||||
let pendingOnleave: ReturnType<typeof setTimeout> | 0;
|
||||
|
||||
onhover &&
|
||||
builder.addHook('setLegend', (u) => {
|
||||
if (u.cursor.idxs != null) {
|
||||
for (let i = 0; i < u.cursor.idxs.length; i++) {
|
||||
const sel = u.cursor.idxs[i];
|
||||
if (sel != null) {
|
||||
const { left, top } = u.cursor;
|
||||
|
||||
if (!isToolTipOpen?.current) {
|
||||
if (pendingOnleave) {
|
||||
clearTimeout(pendingOnleave);
|
||||
pendingOnleave = 0;
|
||||
}
|
||||
onhover({
|
||||
seriesIdx: i,
|
||||
dataIdx: sel,
|
||||
pageX: rect.left + left!,
|
||||
pageY: rect.top + top!,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isToolTipOpen?.current) {
|
||||
// if tiles have gaps, reduce flashing / re-render (debounce onleave by 100ms)
|
||||
if (!pendingOnleave) {
|
||||
pendingOnleave = setTimeout(() => {
|
||||
onhover(null);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
builder.addHook('drawClear', (u) => {
|
||||
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
|
||||
|
||||
@ -583,15 +490,6 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
},
|
||||
};
|
||||
|
||||
if (sync && sync() !== DashboardCursorSync.Off) {
|
||||
cursor.sync = {
|
||||
key: eventsScope,
|
||||
scales: [xScaleKey, null],
|
||||
};
|
||||
|
||||
builder.setSync();
|
||||
}
|
||||
|
||||
builder.setCursor(cursor);
|
||||
|
||||
return builder;
|
||||
|
@ -1,22 +1,9 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { CartesianCoords2D, DashboardCursorSync, DataFrame, FieldType, PanelProps } from '@grafana/data';
|
||||
import { DashboardCursorSync, PanelProps } from '@grafana/data';
|
||||
import { getLastStreamingDataFramePacket } from '@grafana/data/src/dataframe/StreamingDataFrame';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
EventBusPlugin,
|
||||
Portal,
|
||||
TooltipDisplayMode,
|
||||
TooltipPlugin2,
|
||||
UPlotConfigBuilder,
|
||||
usePanelContext,
|
||||
useTheme2,
|
||||
VizTooltipContainer,
|
||||
ZoomPlugin,
|
||||
} from '@grafana/ui';
|
||||
import { addTooltipSupport, HoverEvent } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport';
|
||||
import { EventBusPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext, useTheme2 } from '@grafana/ui';
|
||||
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
|
||||
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
|
||||
import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart';
|
||||
import {
|
||||
prepareTimelineFields,
|
||||
@ -24,18 +11,13 @@ import {
|
||||
TimelineMode,
|
||||
} from 'app/core/components/TimelineChart/utils';
|
||||
|
||||
import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin';
|
||||
import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin';
|
||||
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
|
||||
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
|
||||
import { getTimezones } from '../timeseries/utils';
|
||||
import { getTimezones, isTooltipScrollable } from '../timeseries/utils';
|
||||
|
||||
import { StateTimelineTooltip } from './StateTimelineTooltip';
|
||||
import { StateTimelineTooltip2 } from './StateTimelineTooltip2';
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
const TOOLTIP_OFFSET = 10;
|
||||
|
||||
interface TimelinePanelProps extends PanelProps<Options> {}
|
||||
|
||||
/**
|
||||
@ -53,43 +35,10 @@ export const StateTimelinePanel = ({
|
||||
}: TimelinePanelProps) => {
|
||||
const theme = useTheme2();
|
||||
|
||||
// TODO: we should just re-init when this changes, and have this be a static setting
|
||||
const syncTooltip = useCallback(
|
||||
() => sync?.() === DashboardCursorSync.Tooltip,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const syncAny = useCallback(
|
||||
() => sync?.() !== DashboardCursorSync.Off,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
|
||||
const isToolTipOpen = useRef<boolean>(false);
|
||||
|
||||
const [hover, setHover] = useState<HoverEvent | undefined>(undefined);
|
||||
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null);
|
||||
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||
const [isActive, setIsActive] = useState<boolean>(false);
|
||||
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
|
||||
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
|
||||
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
|
||||
const { sync, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext();
|
||||
|
||||
const onCloseToolTip = () => {
|
||||
isToolTipOpen.current = false;
|
||||
setCoords(null);
|
||||
setShouldDisplayCloseButton(false);
|
||||
};
|
||||
|
||||
const onUPlotClick = () => {
|
||||
isToolTipOpen.current = !isToolTipOpen.current;
|
||||
// Linking into useState required to re-render tooltip
|
||||
setShouldDisplayCloseButton(isToolTipOpen.current);
|
||||
};
|
||||
const { sync, eventsScope, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext();
|
||||
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
|
||||
|
||||
const { frames, warn } = useMemo(
|
||||
() => prepareTimelineFields(data.series, options.mergeValues ?? true, timeRange, theme),
|
||||
@ -103,65 +52,6 @@ export const StateTimelinePanel = ({
|
||||
|
||||
const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]);
|
||||
|
||||
const renderCustomTooltip = useCallback(
|
||||
(alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null, onAnnotationAdd?: () => void) => {
|
||||
const data = frames ?? [];
|
||||
// Count value fields in the state-timeline-ready frame
|
||||
const valueFieldsCount = data.reduce(
|
||||
(acc, frame) => acc + frame.fields.filter((field) => field.type !== FieldType.time).length,
|
||||
0
|
||||
);
|
||||
|
||||
// Not caring about multi mode in StateTimeline
|
||||
if (seriesIdx === null || datapointIdx === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* There could be a case when the tooltip shows a data from one of a multiple query and the other query finishes first
|
||||
* from refreshing. This causes data to be out of sync. alignedData - 1 because Time field doesn't count.
|
||||
* Render nothing in this case to prevent error.
|
||||
* See https://github.com/grafana/support-escalations/issues/932
|
||||
*/
|
||||
if (alignedData.fields.length - 1 !== valueFieldsCount || !alignedData.fields[seriesIdx]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayCloseButton && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CloseButton
|
||||
onClick={onCloseToolTip}
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<StateTimelineTooltip
|
||||
data={data}
|
||||
alignedData={alignedData}
|
||||
seriesIdx={seriesIdx}
|
||||
datapointIdx={datapointIdx}
|
||||
timeZone={timeZone}
|
||||
onAnnotationAdd={onAnnotationAdd}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[timeZone, frames, shouldDisplayCloseButton]
|
||||
);
|
||||
|
||||
if (!frames || warn) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
@ -177,7 +67,6 @@ export const StateTimelinePanel = ({
|
||||
}
|
||||
}
|
||||
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
|
||||
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
|
||||
|
||||
return (
|
||||
<TimelineChart
|
||||
@ -193,132 +82,67 @@ export const StateTimelinePanel = ({
|
||||
mode={TimelineMode.Changes}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinkPostProcessor={dataLinkPostProcessor}
|
||||
cursorSync={cursorSync}
|
||||
>
|
||||
{(builder, alignedFrame) => {
|
||||
if (oldConfig.current !== builder && !showNewVizTooltips) {
|
||||
oldConfig.current = addTooltipSupport({
|
||||
config: builder,
|
||||
onUPlotClick,
|
||||
setFocusedSeriesIdx,
|
||||
setFocusedPointIdx,
|
||||
setCoords,
|
||||
setHover,
|
||||
isToolTipOpen,
|
||||
isActive,
|
||||
setIsActive,
|
||||
sync,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventBusPlugin config={builder} sync={syncAny} eventBus={eventBus} frame={alignedFrame} />
|
||||
{showNewVizTooltips ? (
|
||||
<>
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={builder}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
syncTooltip={syncTooltip}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<StateTimelineTooltip2
|
||||
frames={frames ?? []}
|
||||
seriesFrame={alignedFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
isPinned={isPinned}
|
||||
timeRange={timeRange}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
withDuration={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
)}
|
||||
{/* Renders annotations */}
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={builder}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
canvasRegionRendering={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ZoomPlugin config={builder} onZoom={onChangeTimeRange} />
|
||||
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
|
||||
{/* Renders annotation markers*/}
|
||||
{data.annotations && (
|
||||
<AnnotationsPlugin annotations={data.annotations} config={builder} timeZone={timeZone} />
|
||||
)}
|
||||
|
||||
{enableAnnotationCreation ? (
|
||||
<AnnotationEditorPlugin data={alignedFrame} timeZone={timeZone} config={builder}>
|
||||
{({ startAnnotating }) => {
|
||||
if (options.tooltip.mode === TooltipDisplayMode.None) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (focusedPointIdx === null || (!isActive && sync && sync() === DashboardCursorSync.Crosshair)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
{hover && coords && focusedSeriesIdx && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: coords.viewport.x, y: coords.viewport.y }}
|
||||
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx, () => {
|
||||
startAnnotating({ coords: { plotCanvas: coords.canvas, viewport: coords.viewport } });
|
||||
onCloseToolTip();
|
||||
})}
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}}
|
||||
</AnnotationEditorPlugin>
|
||||
) : (
|
||||
<Portal>
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && hover && coords && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: coords.viewport.x, y: coords.viewport.y }}
|
||||
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx)}
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
{cursorSync !== DashboardCursorSync.Off && (
|
||||
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} />
|
||||
)}
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={builder}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
syncMode={cursorSync}
|
||||
syncScope={eventsScope}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<StateTimelineTooltip2
|
||||
frames={frames ?? []}
|
||||
seriesFrame={alignedFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
isPinned={isPinned}
|
||||
timeRange={timeRange}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
withDuration={true}
|
||||
scrollable={isTooltipScrollable(options.tooltip)}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
/>
|
||||
)}
|
||||
{/* Renders annotations */}
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={builder}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
canvasRegionRendering={false}
|
||||
/>
|
||||
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
@ -1,129 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
FALLBACK_COLOR,
|
||||
Field,
|
||||
getDisplayProcessor,
|
||||
getFieldDisplayName,
|
||||
TimeZone,
|
||||
LinkModel,
|
||||
} from '@grafana/data';
|
||||
import { MenuItem, SeriesTableRow, useTheme2 } from '@grafana/ui';
|
||||
import { findNextStateIndex, fmtDuration } from 'app/core/components/TimelineChart/utils';
|
||||
|
||||
interface StateTimelineTooltipProps {
|
||||
data: DataFrame[];
|
||||
alignedData: DataFrame;
|
||||
seriesIdx: number;
|
||||
datapointIdx: number;
|
||||
timeZone: TimeZone;
|
||||
onAnnotationAdd?: () => void;
|
||||
}
|
||||
|
||||
export const StateTimelineTooltip = ({
|
||||
data,
|
||||
alignedData,
|
||||
seriesIdx,
|
||||
datapointIdx,
|
||||
timeZone,
|
||||
onAnnotationAdd,
|
||||
}: StateTimelineTooltipProps) => {
|
||||
const theme = useTheme2();
|
||||
|
||||
if (!data || datapointIdx == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const field = alignedData.fields[seriesIdx!];
|
||||
|
||||
const links: Array<LinkModel<Field>> = [];
|
||||
const linkLookup = new Set<string>();
|
||||
|
||||
if (field.getLinks) {
|
||||
const v = field.values[datapointIdx];
|
||||
const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v };
|
||||
field.getLinks({ calculatedValue: disp, valueRowIndex: datapointIdx }).forEach((link) => {
|
||||
const key = `${link.title}/${link.href}`;
|
||||
if (!linkLookup.has(key)) {
|
||||
links.push(link);
|
||||
linkLookup.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const xField = alignedData.fields[0];
|
||||
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
|
||||
|
||||
const dataFrameFieldIndex = field.state?.origin;
|
||||
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
|
||||
const value = field.values[datapointIdx!];
|
||||
const display = fieldFmt(value);
|
||||
const fieldDisplayName = dataFrameFieldIndex
|
||||
? getFieldDisplayName(
|
||||
data[dataFrameFieldIndex.frameIndex].fields[dataFrameFieldIndex.fieldIndex],
|
||||
data[dataFrameFieldIndex.frameIndex],
|
||||
data
|
||||
)
|
||||
: null;
|
||||
|
||||
const nextStateIdx = findNextStateIndex(field, datapointIdx!);
|
||||
let nextStateTs;
|
||||
if (nextStateIdx) {
|
||||
nextStateTs = xField.values[nextStateIdx!];
|
||||
}
|
||||
|
||||
const stateTs = xField.values[datapointIdx!];
|
||||
|
||||
let toFragment = null;
|
||||
let durationFragment = null;
|
||||
|
||||
if (nextStateTs) {
|
||||
const duration = nextStateTs && fmtDuration(nextStateTs - stateTs);
|
||||
durationFragment = (
|
||||
<>
|
||||
<br />
|
||||
<strong>Duration:</strong> {duration}
|
||||
</>
|
||||
);
|
||||
toFragment = (
|
||||
<>
|
||||
{' to'} <strong>{xFieldFmt(xField.values[nextStateIdx!]).text}</strong>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: theme.typography.bodySmall.fontSize }}>
|
||||
{fieldDisplayName}
|
||||
<br />
|
||||
<SeriesTableRow label={display.text} color={display.color || FALLBACK_COLOR} isActive />
|
||||
From <strong>{xFieldFmt(xField.values[datapointIdx!]).text}</strong>
|
||||
{toFragment}
|
||||
{durationFragment}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
margin: theme.spacing(1, -1, -1, -1),
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
}}
|
||||
>
|
||||
{onAnnotationAdd && <MenuItem label={'Add annotation'} icon={'comment-alt'} onClick={onAnnotationAdd} />}
|
||||
{links.length > 0 &&
|
||||
links.map((link, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
icon={'external-link-alt'}
|
||||
target={link.target}
|
||||
label={link.title}
|
||||
url={link.href}
|
||||
onClick={link.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
StateTimelineTooltip.displayName = 'StateTimelineTooltip';
|
@ -30,6 +30,7 @@ export const StateTimelineTooltip2 = ({
|
||||
annotate,
|
||||
timeRange,
|
||||
withDuration,
|
||||
maxHeight,
|
||||
}: StateTimelineTooltip2Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
@ -83,7 +84,7 @@ export const StateTimelineTooltip2 = ({
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<VizTooltipHeader item={headerItem} isPinned={isPinned} />
|
||||
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} />
|
||||
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} maxHeight={maxHeight} />
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,21 +1,8 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { CartesianCoords2D, DashboardCursorSync, DataFrame, FieldType, PanelProps } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
EventBusPlugin,
|
||||
Portal,
|
||||
TooltipDisplayMode,
|
||||
TooltipPlugin2,
|
||||
UPlotConfigBuilder,
|
||||
usePanelContext,
|
||||
useTheme2,
|
||||
VizTooltipContainer,
|
||||
ZoomPlugin,
|
||||
} from '@grafana/ui';
|
||||
import { addTooltipSupport, HoverEvent } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport';
|
||||
import { DashboardCursorSync, PanelProps } from '@grafana/data';
|
||||
import { EventBusPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext, useTheme2 } from '@grafana/ui';
|
||||
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
|
||||
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
|
||||
import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart';
|
||||
import {
|
||||
prepareTimelineFields,
|
||||
@ -24,16 +11,12 @@ import {
|
||||
} from 'app/core/components/TimelineChart/utils';
|
||||
|
||||
import { StateTimelineTooltip2 } from '../state-timeline/StateTimelineTooltip2';
|
||||
import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin';
|
||||
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
|
||||
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
|
||||
import { getTimezones } from '../timeseries/utils';
|
||||
import { getTimezones, isTooltipScrollable } from '../timeseries/utils';
|
||||
|
||||
import { StatusHistoryTooltip } from './StatusHistoryTooltip';
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
const TOOLTIP_OFFSET = 10;
|
||||
|
||||
interface TimelinePanelProps extends PanelProps<Options> {}
|
||||
|
||||
/**
|
||||
@ -51,47 +34,13 @@ export const StatusHistoryPanel = ({
|
||||
}: TimelinePanelProps) => {
|
||||
const theme = useTheme2();
|
||||
|
||||
// TODO: we should just re-init when this changes, and have this be a static setting
|
||||
const syncTooltip = useCallback(
|
||||
() => sync?.() === DashboardCursorSync.Tooltip,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const syncAny = useCallback(
|
||||
() => sync?.() !== DashboardCursorSync.Off,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
|
||||
const isToolTipOpen = useRef<boolean>(false);
|
||||
|
||||
const [hover, setHover] = useState<HoverEvent | undefined>(undefined);
|
||||
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null);
|
||||
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||
const [isActive, setIsActive] = useState<boolean>(false);
|
||||
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
|
||||
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
|
||||
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
|
||||
const { sync, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext();
|
||||
const { sync, eventsScope, canAddAnnotations, dataLinkPostProcessor, eventBus } = usePanelContext();
|
||||
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
|
||||
|
||||
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
|
||||
|
||||
const onCloseToolTip = () => {
|
||||
isToolTipOpen.current = false;
|
||||
setCoords(null);
|
||||
setShouldDisplayCloseButton(false);
|
||||
};
|
||||
|
||||
const onUPlotClick = () => {
|
||||
isToolTipOpen.current = !isToolTipOpen.current;
|
||||
|
||||
// Linking into useState required to re-render tooltip
|
||||
setShouldDisplayCloseButton(isToolTipOpen.current);
|
||||
};
|
||||
|
||||
const { frames, warn } = useMemo(
|
||||
() => prepareTimelineFields(data.series, false, timeRange, theme),
|
||||
[data.series, timeRange, theme]
|
||||
@ -102,89 +51,6 @@ export const StatusHistoryPanel = ({
|
||||
[frames, options.legend, theme]
|
||||
);
|
||||
|
||||
const renderCustomTooltip = useCallback(
|
||||
(alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => {
|
||||
const data = frames ?? [];
|
||||
|
||||
// Count value fields in the state-timeline-ready frame
|
||||
const valueFieldsCount = data.reduce(
|
||||
(acc, frame) => acc + frame.fields.filter((field) => field.type !== FieldType.time).length,
|
||||
0
|
||||
);
|
||||
|
||||
// Not caring about multi mode in StatusHistory
|
||||
if (seriesIdx === null || datapointIdx === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* There could be a case when the tooltip shows a data from one of a multiple query and the other query finishes first
|
||||
* from refreshing. This causes data to be out of sync. alignedData - 1 because Time field doesn't count.
|
||||
* Render nothing in this case to prevent error.
|
||||
* See https://github.com/grafana/support-escalations/issues/932
|
||||
*/
|
||||
if (alignedData.fields.length - 1 !== valueFieldsCount || !alignedData.fields[seriesIdx]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayCloseButton && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CloseButton
|
||||
onClick={onCloseToolTip}
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<StatusHistoryTooltip
|
||||
data={data}
|
||||
alignedData={alignedData}
|
||||
seriesIdx={seriesIdx}
|
||||
datapointIdx={datapointIdx}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[timeZone, frames, shouldDisplayCloseButton]
|
||||
);
|
||||
|
||||
const renderTooltip = (alignedFrame: DataFrame) => {
|
||||
if (options.tooltip.mode === TooltipDisplayMode.None) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (focusedPointIdx === null || (!isActive && sync && sync() === DashboardCursorSync.Crosshair)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
{hover && coords && focusedSeriesIdx && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: coords.viewport.x, y: coords.viewport.y }}
|
||||
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx)}
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]);
|
||||
|
||||
if (!frames || warn) {
|
||||
@ -207,8 +73,6 @@ export const StatusHistoryPanel = ({
|
||||
);
|
||||
}
|
||||
|
||||
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
|
||||
|
||||
return (
|
||||
<TimelineChart
|
||||
theme={theme}
|
||||
@ -223,92 +87,66 @@ export const StatusHistoryPanel = ({
|
||||
mode={TimelineMode.Samples}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinkPostProcessor={dataLinkPostProcessor}
|
||||
cursorSync={cursorSync}
|
||||
>
|
||||
{(builder, alignedFrame) => {
|
||||
if (oldConfig.current !== builder && !showNewVizTooltips) {
|
||||
oldConfig.current = addTooltipSupport({
|
||||
config: builder,
|
||||
onUPlotClick,
|
||||
setFocusedSeriesIdx,
|
||||
setFocusedPointIdx,
|
||||
setCoords,
|
||||
setHover,
|
||||
isToolTipOpen,
|
||||
isActive,
|
||||
setIsActive,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventBusPlugin config={builder} sync={syncAny} eventBus={eventBus} frame={alignedFrame} />
|
||||
{showNewVizTooltips ? (
|
||||
<>
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={builder}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
syncTooltip={syncTooltip}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<StateTimelineTooltip2
|
||||
frames={frames ?? []}
|
||||
seriesFrame={alignedFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
isPinned={isPinned}
|
||||
timeRange={timeRange}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
withDuration={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
)}
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={builder}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
canvasRegionRendering={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ZoomPlugin config={builder} onZoom={onChangeTimeRange} />
|
||||
{renderTooltip(alignedFrame)}
|
||||
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
|
||||
{data.annotations && (
|
||||
<AnnotationsPlugin
|
||||
annotations={data.annotations}
|
||||
config={builder}
|
||||
timeZone={timeZone}
|
||||
disableCanvasRendering={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{cursorSync !== DashboardCursorSync.Off && (
|
||||
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} />
|
||||
)}
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={builder}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
syncMode={cursorSync}
|
||||
syncScope={eventsScope}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<StateTimelineTooltip2
|
||||
frames={frames ?? []}
|
||||
seriesFrame={alignedFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
isPinned={isPinned}
|
||||
timeRange={timeRange}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
withDuration={false}
|
||||
scrollable={isTooltipScrollable(options.tooltip)}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
/>
|
||||
)}
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={builder}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
canvasRegionRendering={false}
|
||||
/>
|
||||
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
FALLBACK_COLOR,
|
||||
Field,
|
||||
getDisplayProcessor,
|
||||
getFieldDisplayName,
|
||||
TimeZone,
|
||||
LinkModel,
|
||||
} from '@grafana/data';
|
||||
import { MenuItem, SeriesTableRow, useTheme2 } from '@grafana/ui';
|
||||
|
||||
interface StatusHistoryTooltipProps {
|
||||
data: DataFrame[];
|
||||
alignedData: DataFrame;
|
||||
seriesIdx: number;
|
||||
datapointIdx: number;
|
||||
timeZone: TimeZone;
|
||||
}
|
||||
|
||||
export const StatusHistoryTooltip = ({
|
||||
data,
|
||||
alignedData,
|
||||
seriesIdx,
|
||||
datapointIdx,
|
||||
timeZone,
|
||||
}: StatusHistoryTooltipProps) => {
|
||||
const theme = useTheme2();
|
||||
|
||||
if (!data || datapointIdx == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const field = alignedData.fields[seriesIdx!];
|
||||
|
||||
const links: Array<LinkModel<Field>> = [];
|
||||
const linkLookup = new Set<string>();
|
||||
|
||||
if (field.getLinks) {
|
||||
const v = field.values[datapointIdx];
|
||||
const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v };
|
||||
field.getLinks({ calculatedValue: disp, valueRowIndex: datapointIdx }).forEach((link) => {
|
||||
const key = `${link.title}/${link.href}`;
|
||||
if (!linkLookup.has(key)) {
|
||||
links.push(link);
|
||||
linkLookup.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const xField = alignedData.fields[0];
|
||||
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
|
||||
|
||||
const dataFrameFieldIndex = field.state?.origin;
|
||||
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
|
||||
const value = field.values[datapointIdx!];
|
||||
const display = fieldFmt(value);
|
||||
const fieldDisplayName = dataFrameFieldIndex
|
||||
? getFieldDisplayName(
|
||||
data[dataFrameFieldIndex.frameIndex].fields[dataFrameFieldIndex.fieldIndex],
|
||||
data[dataFrameFieldIndex.frameIndex],
|
||||
data
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: theme.typography.bodySmall.fontSize }}>
|
||||
<strong>{xFieldFmt(xField.values[datapointIdx]).text}</strong>
|
||||
<br />
|
||||
<SeriesTableRow label={display.text} color={display.color || FALLBACK_COLOR} isActive />
|
||||
{fieldDisplayName}
|
||||
</div>
|
||||
{links.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
margin: theme.spacing(1, -1, -1, -1),
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
}}
|
||||
>
|
||||
{links.map((link, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
icon={'external-link-alt'}
|
||||
target={link.target}
|
||||
label={link.title}
|
||||
url={link.href}
|
||||
onClick={link.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
StatusHistoryTooltip.displayName = 'StatusHistoryTooltip';
|
@ -1,26 +1,16 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { PanelProps, DataFrameType, DashboardCursorSync } from '@grafana/data';
|
||||
import { PanelDataErrorView } from '@grafana/runtime';
|
||||
import { TooltipDisplayMode, VizOrientation } from '@grafana/schema';
|
||||
import {
|
||||
EventBusPlugin,
|
||||
KeyboardPlugin,
|
||||
TooltipPlugin,
|
||||
TooltipPlugin2,
|
||||
usePanelContext,
|
||||
ZoomPlugin,
|
||||
} from '@grafana/ui';
|
||||
import { EventBusPlugin, KeyboardPlugin, TooltipPlugin2, usePanelContext } from '@grafana/ui';
|
||||
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
|
||||
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
import { TimeSeriesTooltip } from './TimeSeriesTooltip';
|
||||
import { Options } from './panelcfg.gen';
|
||||
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
|
||||
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
|
||||
import { AnnotationsPlugin2 } from './plugins/AnnotationsPlugin2';
|
||||
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
|
||||
import { ExemplarsPlugin, getVisibleLabels } from './plugins/ExemplarsPlugin';
|
||||
import { OutsideRangePlugin } from './plugins/OutsideRangePlugin';
|
||||
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
|
||||
@ -43,6 +33,7 @@ export const TimeSeriesPanel = ({
|
||||
}: TimeSeriesPanelProps) => {
|
||||
const {
|
||||
sync,
|
||||
eventsScope,
|
||||
canAddAnnotations,
|
||||
onThresholdsChange,
|
||||
canEditThresholds,
|
||||
@ -67,22 +58,8 @@ export const TimeSeriesPanel = ({
|
||||
}, [frames, id]);
|
||||
|
||||
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
|
||||
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
|
||||
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
|
||||
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
|
||||
|
||||
// TODO: we should just re-init when this changes, and have this be a static setting
|
||||
const syncTooltip = useCallback(
|
||||
() => sync?.() === DashboardCursorSync.Tooltip,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const syncAny = useCallback(
|
||||
() => sync?.() !== DashboardCursorSync.Off,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
const cursorSync = sync?.() ?? DashboardCursorSync.Off;
|
||||
|
||||
if (!frames || suggestions) {
|
||||
return (
|
||||
@ -98,14 +75,6 @@ export const TimeSeriesPanel = ({
|
||||
);
|
||||
}
|
||||
|
||||
// which annotation are we editing?
|
||||
// are we adding a new annotation? is annotating?
|
||||
// console.log(data.annotations);
|
||||
|
||||
// annotations plugin includes the editor and the renderer
|
||||
// its annotation state is managed here for now
|
||||
// tooltipplugin2 receives render with annotate range, callback should setstate here that gets passed to annotationsplugin as newAnnotaton or editAnnotation
|
||||
|
||||
return (
|
||||
<TimeSeries
|
||||
frames={frames}
|
||||
@ -118,148 +87,85 @@ export const TimeSeriesPanel = ({
|
||||
options={options}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinkPostProcessor={dataLinkPostProcessor}
|
||||
cursorSync={cursorSync}
|
||||
>
|
||||
{(uplotConfig, alignedFrame) => {
|
||||
return (
|
||||
<>
|
||||
<KeyboardPlugin config={uplotConfig} />
|
||||
<EventBusPlugin config={uplotConfig} sync={syncAny} eventBus={eventBus} frame={alignedFrame} />
|
||||
{options.tooltip.mode === TooltipDisplayMode.None || (
|
||||
<>
|
||||
{showNewVizTooltips ? (
|
||||
<TooltipPlugin2
|
||||
config={uplotConfig}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
clientZoom={true}
|
||||
syncTooltip={syncTooltip}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
{cursorSync !== DashboardCursorSync.Off && (
|
||||
<EventBusPlugin config={uplotConfig} eventBus={eventBus} frame={alignedFrame} />
|
||||
)}
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={uplotConfig}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
clientZoom={true}
|
||||
syncMode={cursorSync}
|
||||
syncScope={eventsScope}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
|
||||
<TimeSeriesTooltip
|
||||
frames={frames}
|
||||
seriesFrame={alignedFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
isPinned={isPinned}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
scrollable={isTooltipScrollable(options.tooltip)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ZoomPlugin config={uplotConfig} onZoom={onChangeTimeRange} withZoomY={true} />
|
||||
<TooltipPlugin
|
||||
return (
|
||||
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
|
||||
<TimeSeriesTooltip
|
||||
frames={frames}
|
||||
data={alignedFrame}
|
||||
config={uplotConfig}
|
||||
mode={options.tooltip.mode}
|
||||
seriesFrame={alignedFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
sync={sync}
|
||||
timeZone={timeZone}
|
||||
isPinned={isPinned}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
scrollable={isTooltipScrollable(options.tooltip)}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
/>
|
||||
)}
|
||||
{!isVerticallyOriented && (
|
||||
<>
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={uplotConfig}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
/>
|
||||
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
|
||||
{data.annotations && (
|
||||
<ExemplarsPlugin
|
||||
visibleSeries={getVisibleLabels(uplotConfig, frames)}
|
||||
config={uplotConfig}
|
||||
exemplars={data.annotations}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
)}
|
||||
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
|
||||
<ThresholdControlsPlugin
|
||||
config={uplotConfig}
|
||||
fieldConfig={fieldConfig}
|
||||
onThresholdsChange={canEditThresholds ? onThresholdsChange : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Renders annotation markers*/}
|
||||
{!isVerticallyOriented && showNewVizTooltips ? (
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={uplotConfig}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
/>
|
||||
) : (
|
||||
!isVerticallyOriented &&
|
||||
data.annotations && (
|
||||
<AnnotationsPlugin annotations={data.annotations} config={uplotConfig} timeZone={timeZone} />
|
||||
)
|
||||
)}
|
||||
|
||||
{/*Enables annotations creation*/}
|
||||
{!showNewVizTooltips ? (
|
||||
enableAnnotationCreation && !isVerticallyOriented ? (
|
||||
<AnnotationEditorPlugin data={alignedFrame} timeZone={timeZone} config={uplotConfig}>
|
||||
{({ startAnnotating }) => {
|
||||
return (
|
||||
<ContextMenuPlugin
|
||||
data={alignedFrame}
|
||||
config={uplotConfig}
|
||||
timeZone={timeZone}
|
||||
replaceVariables={replaceVariables}
|
||||
defaultItems={[
|
||||
{
|
||||
items: [
|
||||
{
|
||||
label: 'Add annotation',
|
||||
ariaLabel: 'Add annotation',
|
||||
icon: 'comment-alt',
|
||||
onClick: (e, p) => {
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
startAnnotating({ coords: p.coords });
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</AnnotationEditorPlugin>
|
||||
) : (
|
||||
<ContextMenuPlugin
|
||||
data={alignedFrame}
|
||||
frames={frames}
|
||||
config={uplotConfig}
|
||||
timeZone={timeZone}
|
||||
replaceVariables={replaceVariables}
|
||||
defaultItems={[]}
|
||||
/>
|
||||
)
|
||||
) : undefined}
|
||||
{data.annotations && !isVerticallyOriented && (
|
||||
<ExemplarsPlugin
|
||||
visibleSeries={getVisibleLabels(uplotConfig, frames)}
|
||||
config={uplotConfig}
|
||||
exemplars={data.annotations}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
)}
|
||||
|
||||
{((canEditThresholds && onThresholdsChange) || showThresholds) && !isVerticallyOriented && (
|
||||
<ThresholdControlsPlugin
|
||||
config={uplotConfig}
|
||||
fieldConfig={fieldConfig}
|
||||
onThresholdsChange={canEditThresholds ? onThresholdsChange : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
@ -30,6 +30,7 @@ export interface TimeSeriesTooltipProps {
|
||||
scrollable?: boolean;
|
||||
|
||||
annotate?: () => void;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const TimeSeriesTooltip = ({
|
||||
@ -42,6 +43,7 @@ export const TimeSeriesTooltip = ({
|
||||
scrollable = false,
|
||||
isPinned,
|
||||
annotate,
|
||||
maxHeight,
|
||||
}: TimeSeriesTooltipProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
@ -77,7 +79,7 @@ export const TimeSeriesTooltip = ({
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<VizTooltipHeader item={headerItem} isPinned={isPinned} />
|
||||
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} />
|
||||
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} maxHeight={maxHeight} />
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,158 +0,0 @@
|
||||
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useMountedState } from 'react-use';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { CartesianCoords2D, DataFrame, TimeZone } from '@grafana/data';
|
||||
import { PlotSelection, UPlotConfigBuilder } from '@grafana/ui';
|
||||
|
||||
import { AnnotationEditor } from './annotations/AnnotationEditor';
|
||||
|
||||
type StartAnnotatingFn = (props: {
|
||||
// pixel coordinates of the clicked point on the uPlot canvas
|
||||
coords: { viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null;
|
||||
}) => void;
|
||||
|
||||
interface AnnotationEditorPluginProps {
|
||||
data: DataFrame;
|
||||
timeZone: TimeZone;
|
||||
config: UPlotConfigBuilder;
|
||||
children?: (props: { startAnnotating: StartAnnotatingFn }) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const AnnotationEditorPlugin = ({ data, timeZone, config, children }: AnnotationEditorPluginProps) => {
|
||||
const plotInstance = useRef<uPlot>();
|
||||
const [bbox, setBbox] = useState<DOMRect>();
|
||||
const [isAddingAnnotation, setIsAddingAnnotation] = useState(false);
|
||||
const [selection, setSelection] = useState<PlotSelection | null>(null);
|
||||
const isMounted = useMountedState();
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelection(null);
|
||||
|
||||
if (plotInstance.current) {
|
||||
plotInstance.current.setSelect({ top: 0, left: 0, width: 0, height: 0 });
|
||||
}
|
||||
setIsAddingAnnotation(false);
|
||||
}, [setIsAddingAnnotation, setSelection]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let annotating = false;
|
||||
|
||||
config.addHook('init', (u) => {
|
||||
plotInstance.current = u;
|
||||
// Wrap all setSelect hooks to prevent them from firing if user is annotating
|
||||
const setSelectHooks = u.hooks.setSelect;
|
||||
|
||||
if (setSelectHooks) {
|
||||
for (let i = 0; i < setSelectHooks.length; i++) {
|
||||
const hook = setSelectHooks[i];
|
||||
|
||||
if (hook !== setSelect) {
|
||||
setSelectHooks[i] = (...args) => {
|
||||
!annotating && hook!(...args);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// cache uPlot plotting area bounding box
|
||||
config.addHook('syncRect', (u, rect) => {
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
setBbox(rect);
|
||||
});
|
||||
|
||||
const setSelect = (u: uPlot) => {
|
||||
if (annotating) {
|
||||
setIsAddingAnnotation(true);
|
||||
setSelection({
|
||||
min: u.posToVal(u.select.left, 'x'),
|
||||
max: u.posToVal(u.select.left + u.select.width, 'x'),
|
||||
bbox: {
|
||||
left: u.select.left,
|
||||
top: 0,
|
||||
height: u.select.height,
|
||||
width: u.select.width,
|
||||
},
|
||||
});
|
||||
annotating = false;
|
||||
}
|
||||
};
|
||||
|
||||
config.addHook('setSelect', setSelect);
|
||||
|
||||
config.setCursor({
|
||||
bind: {
|
||||
mousedown: (u, targ, handler) => (e) => {
|
||||
annotating = e.button === 0 && (e.metaKey || e.ctrlKey);
|
||||
handler(e);
|
||||
return null;
|
||||
},
|
||||
mouseup: (u, targ, handler) => (e) => {
|
||||
// uPlot will not fire setSelect hooks for 0-width && 0-height selections
|
||||
// so we force it to fire on single-point clicks by mutating left & height
|
||||
if (annotating && u.select.width === 0) {
|
||||
u.select.left = u.cursor.left!;
|
||||
u.select.height = u.bbox.height / window.devicePixelRatio;
|
||||
}
|
||||
handler(e);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [config, setBbox, isMounted]);
|
||||
|
||||
const startAnnotating = useCallback<StartAnnotatingFn>(
|
||||
({ coords }) => {
|
||||
if (!plotInstance.current || !bbox || !coords) {
|
||||
return;
|
||||
}
|
||||
|
||||
const min = plotInstance.current.posToVal(coords.plotCanvas.x, 'x');
|
||||
|
||||
if (!min) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelection({
|
||||
min,
|
||||
max: min,
|
||||
bbox: {
|
||||
left: coords.plotCanvas.x,
|
||||
top: 0,
|
||||
height: bbox.height,
|
||||
width: 0,
|
||||
},
|
||||
});
|
||||
setIsAddingAnnotation(true);
|
||||
},
|
||||
[bbox]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isAddingAnnotation && selection && bbox && (
|
||||
<AnnotationEditor
|
||||
selection={selection}
|
||||
onDismiss={clearSelection}
|
||||
onSave={clearSelection}
|
||||
data={data}
|
||||
timeZone={timeZone}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${bbox.top}px`,
|
||||
left: `${bbox.left}px`,
|
||||
width: `${bbox.width}px`,
|
||||
height: `${bbox.height}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children ? children({ startAnnotating }) : null}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,165 +0,0 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { colorManipulator, DataFrame, DataFrameFieldIndex, DataFrameView, TimeZone } from '@grafana/data';
|
||||
import { EventsCanvas, UPlotConfigBuilder, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { AnnotationMarker } from './annotations/AnnotationMarker';
|
||||
import { AnnotationsDataFrameViewDTO } from './types';
|
||||
|
||||
interface AnnotationsPluginProps {
|
||||
config: UPlotConfigBuilder;
|
||||
annotations: DataFrame[];
|
||||
timeZone: TimeZone;
|
||||
disableCanvasRendering?: boolean;
|
||||
}
|
||||
|
||||
export const AnnotationsPlugin = ({
|
||||
annotations,
|
||||
timeZone,
|
||||
config,
|
||||
disableCanvasRendering = false,
|
||||
}: AnnotationsPluginProps) => {
|
||||
const theme = useTheme2();
|
||||
const plotInstance = useRef<uPlot>();
|
||||
|
||||
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
|
||||
|
||||
// Update annotations views when new annotations came
|
||||
useEffect(() => {
|
||||
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
|
||||
|
||||
for (const frame of annotations) {
|
||||
views.push(new DataFrameView(frame));
|
||||
}
|
||||
|
||||
annotationsRef.current = views;
|
||||
|
||||
return () => {
|
||||
// clear on unmount
|
||||
annotationsRef.current = [];
|
||||
};
|
||||
}, [annotations]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
config.addHook('init', (u) => {
|
||||
plotInstance.current = u;
|
||||
});
|
||||
|
||||
config.addHook('draw', (u) => {
|
||||
// Render annotation lines on the canvas
|
||||
/**
|
||||
* We cannot rely on state value here, as it would require this effect to be dependent on the state value.
|
||||
*/
|
||||
if (!annotationsRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ctx = u.ctx;
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
ctx.clip();
|
||||
|
||||
const renderLine = (x: number, color: string) => {
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.moveTo(x, u.bbox.top);
|
||||
ctx.lineTo(x, u.bbox.top + u.bbox.height);
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
};
|
||||
|
||||
if (!disableCanvasRendering) {
|
||||
for (let i = 0; i < annotationsRef.current.length; i++) {
|
||||
const annotationsView = annotationsRef.current[i];
|
||||
for (let j = 0; j < annotationsView.length; j++) {
|
||||
const annotation = annotationsView.get(j);
|
||||
|
||||
if (!annotation.time) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let x0 = u.valToPos(annotation.time, 'x', true);
|
||||
const color = theme.visualization.getColorByName(annotation.color);
|
||||
|
||||
renderLine(x0, color);
|
||||
|
||||
if (annotation.isRegion && annotation.timeEnd) {
|
||||
let x1 = u.valToPos(annotation.timeEnd, 'x', true);
|
||||
|
||||
renderLine(x1, color);
|
||||
|
||||
ctx.fillStyle = colorManipulator.alpha(color, 0.1);
|
||||
ctx.rect(x0, u.bbox.top, x1 - x0, u.bbox.height);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
return;
|
||||
});
|
||||
}, [config, theme, disableCanvasRendering]);
|
||||
|
||||
const mapAnnotationToXYCoords = useCallback((frame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
|
||||
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
|
||||
const annotation = view.get(dataFrameFieldIndex.fieldIndex);
|
||||
|
||||
if (!annotation.time || !plotInstance.current) {
|
||||
return undefined;
|
||||
}
|
||||
let x = plotInstance.current.valToPos(annotation.time, 'x');
|
||||
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
return {
|
||||
x,
|
||||
y: plotInstance.current.bbox.height / window.devicePixelRatio + 4,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderMarker = useCallback(
|
||||
(frame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
|
||||
let width = 0;
|
||||
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
|
||||
const annotation = view.get(dataFrameFieldIndex.fieldIndex);
|
||||
const isRegionAnnotation = Boolean(annotation.isRegion);
|
||||
|
||||
if (isRegionAnnotation && plotInstance.current) {
|
||||
let x0 = plotInstance.current.valToPos(annotation.time, 'x');
|
||||
let x1 = plotInstance.current.valToPos(annotation.timeEnd, 'x');
|
||||
|
||||
// markers are rendered relatively to uPlot canvas overly, not caring about axes width
|
||||
if (x0 < 0) {
|
||||
x0 = 0;
|
||||
}
|
||||
|
||||
if (x1 > plotInstance.current.bbox.width / window.devicePixelRatio) {
|
||||
x1 = plotInstance.current.bbox.width / window.devicePixelRatio;
|
||||
}
|
||||
width = x1 - x0;
|
||||
}
|
||||
|
||||
return <AnnotationMarker annotation={annotation} timeZone={timeZone} width={width} />;
|
||||
},
|
||||
[timeZone]
|
||||
);
|
||||
|
||||
return (
|
||||
<EventsCanvas
|
||||
id="annotations"
|
||||
config={config}
|
||||
events={annotations}
|
||||
renderEventMarker={renderMarker}
|
||||
mapEventToXYCoords={mapAnnotationToXYCoords}
|
||||
/>
|
||||
);
|
||||
};
|
@ -187,13 +187,13 @@ export const AnnotationsPlugin2 = ({
|
||||
|
||||
for (let i = 0; i < vals.time.length; i++) {
|
||||
let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR);
|
||||
let left = plot.valToPos(vals.time[i], 'x');
|
||||
let left = Math.round(plot.valToPos(vals.time[i], 'x')) || 0; // handles -0
|
||||
let style: React.CSSProperties | null = null;
|
||||
let className = '';
|
||||
let isVisible = true;
|
||||
|
||||
if (vals.isRegion?.[i]) {
|
||||
let right = plot.valToPos(vals.timeEnd?.[i], 'x');
|
||||
let right = Math.round(plot.valToPos(vals.timeEnd?.[i], 'x')) || 0; // handles -0
|
||||
|
||||
isVisible = left < plot.rect.width && right > 0;
|
||||
|
||||
@ -205,7 +205,7 @@ export const AnnotationsPlugin2 = ({
|
||||
className = styles.annoRegion;
|
||||
}
|
||||
} else {
|
||||
isVisible = left > 0 && left <= plot.rect.width;
|
||||
isVisible = left >= 0 && left <= plot.rect.width;
|
||||
|
||||
if (isVisible) {
|
||||
style = { left, borderBottomColor: color };
|
||||
|
@ -1,230 +0,0 @@
|
||||
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useClickAway } from 'react-use';
|
||||
|
||||
import { CartesianCoords2D, DataFrame, getFieldDisplayName, InterpolateFunction, TimeZone } from '@grafana/data';
|
||||
import {
|
||||
ContextMenu,
|
||||
GraphContextMenuHeader,
|
||||
MenuItemProps,
|
||||
MenuItemsGroup,
|
||||
MenuGroup,
|
||||
MenuItem,
|
||||
UPlotConfigBuilder,
|
||||
} from '@grafana/ui';
|
||||
|
||||
type ContextMenuSelectionCoords = { viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D };
|
||||
type ContextMenuSelectionPoint = { seriesIdx: number | null; dataIdx: number | null };
|
||||
|
||||
export interface ContextMenuItemClickPayload {
|
||||
coords: ContextMenuSelectionCoords;
|
||||
}
|
||||
|
||||
interface ContextMenuPluginProps {
|
||||
data: DataFrame;
|
||||
frames?: DataFrame[];
|
||||
config: UPlotConfigBuilder;
|
||||
defaultItems?: Array<MenuItemsGroup<ContextMenuItemClickPayload>>;
|
||||
timeZone: TimeZone;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
replaceVariables?: InterpolateFunction;
|
||||
}
|
||||
|
||||
export const ContextMenuPlugin = ({
|
||||
data,
|
||||
config,
|
||||
onClose,
|
||||
timeZone,
|
||||
replaceVariables,
|
||||
...otherProps
|
||||
}: ContextMenuPluginProps) => {
|
||||
const [coords, setCoords] = useState<ContextMenuSelectionCoords | null>(null);
|
||||
const [point, setPoint] = useState<ContextMenuSelectionPoint | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let seriesIdx: number | null = null;
|
||||
|
||||
config.addHook('init', (u) => {
|
||||
u.over.addEventListener('click', (e) => {
|
||||
// only open when have a focused point, and not for explicit annotations, zooms, etc.
|
||||
if (seriesIdx != null && !e.metaKey && !e.ctrlKey && !e.shiftKey) {
|
||||
setCoords({
|
||||
viewport: {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
},
|
||||
plotCanvas: {
|
||||
x: e.clientX - u.rect.left,
|
||||
y: e.clientY - u.rect.top,
|
||||
},
|
||||
});
|
||||
setPoint({ seriesIdx, dataIdx: u.cursor.idxs![seriesIdx] });
|
||||
setIsOpen(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
config.addHook('setSeries', (u, _seriesIdx) => {
|
||||
seriesIdx = _seriesIdx;
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
const defaultItems = useMemo(() => {
|
||||
return otherProps.defaultItems
|
||||
? otherProps.defaultItems.map((i) => {
|
||||
return {
|
||||
...i,
|
||||
items: i.items.map((j) => {
|
||||
return {
|
||||
...j,
|
||||
onClick: (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!coords) {
|
||||
return;
|
||||
}
|
||||
|
||||
j.onClick?.(e, { coords });
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
}, [coords, otherProps.defaultItems]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && coords && (
|
||||
<ContextMenuView
|
||||
data={data}
|
||||
frames={otherProps.frames}
|
||||
defaultItems={defaultItems}
|
||||
timeZone={timeZone}
|
||||
selection={{ point, coords }}
|
||||
replaceVariables={replaceVariables}
|
||||
onClose={() => {
|
||||
setPoint(null);
|
||||
setIsOpen(false);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContextMenuViewProps {
|
||||
data: DataFrame;
|
||||
frames?: DataFrame[];
|
||||
defaultItems?: MenuItemsGroup[];
|
||||
timeZone: TimeZone;
|
||||
onClose?: () => void;
|
||||
selection: {
|
||||
point?: { seriesIdx: number | null; dataIdx: number | null } | null;
|
||||
coords: { plotCanvas: CartesianCoords2D; viewport: CartesianCoords2D };
|
||||
};
|
||||
replaceVariables?: InterpolateFunction;
|
||||
}
|
||||
|
||||
export const ContextMenuView = ({
|
||||
selection,
|
||||
timeZone,
|
||||
defaultItems,
|
||||
replaceVariables,
|
||||
data,
|
||||
...otherProps
|
||||
}: ContextMenuViewProps) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
const onClose = () => {
|
||||
if (otherProps.onClose) {
|
||||
otherProps.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
useClickAway(ref, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
const xField = data.fields[0];
|
||||
|
||||
if (!xField) {
|
||||
return null;
|
||||
}
|
||||
const items = defaultItems ? [...defaultItems] : [];
|
||||
let renderHeader: () => JSX.Element | null = () => null;
|
||||
|
||||
if (selection.point) {
|
||||
const { seriesIdx, dataIdx } = selection.point;
|
||||
const xFieldFmt = xField.display!;
|
||||
|
||||
if (seriesIdx && dataIdx !== null) {
|
||||
const field = data.fields[seriesIdx];
|
||||
|
||||
const displayValue = field.display!(field.values[dataIdx]);
|
||||
|
||||
const hasLinks = field.config.links && field.config.links.length > 0;
|
||||
|
||||
if (hasLinks) {
|
||||
if (field.getLinks) {
|
||||
items.push({
|
||||
items: field
|
||||
.getLinks({
|
||||
valueRowIndex: dataIdx,
|
||||
})
|
||||
.map<MenuItemProps>((link) => {
|
||||
return {
|
||||
label: link.title,
|
||||
ariaLabel: link.title,
|
||||
url: link.href,
|
||||
target: link.target,
|
||||
icon: link.target === '_self' ? 'link' : 'external-link-alt',
|
||||
onClick: link.onClick,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderHeader = () => (
|
||||
<GraphContextMenuHeader
|
||||
timestamp={xFieldFmt(xField.values[dataIdx]).text}
|
||||
displayValue={displayValue}
|
||||
seriesColor={displayValue.color!}
|
||||
displayName={getFieldDisplayName(field, data, otherProps.frames)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renderMenuGroupItems = () => {
|
||||
return items?.map((group, index) => (
|
||||
<MenuGroup key={`${group.label}${index}`} label={group.label}>
|
||||
{(group.items || []).map((item) => (
|
||||
<MenuItem
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
label={item.label}
|
||||
target={item.target}
|
||||
icon={item.icon}
|
||||
active={item.active}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
))}
|
||||
</MenuGroup>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
renderMenuItems={renderMenuGroupItems}
|
||||
renderHeader={renderHeader}
|
||||
x={selection.coords.viewport.x}
|
||||
y={selection.coords.viewport.y}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
@ -1,141 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { autoUpdate, flip, shift, useDismiss, useFloating, useInteractions } from '@floating-ui/react';
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
|
||||
import { colorManipulator, DataFrame, getDisplayProcessor, GrafanaTheme2, TimeZone } from '@grafana/data';
|
||||
import { PlotSelection, useStyles2, useTheme2, Portal, DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
|
||||
|
||||
import { getCommonAnnotationStyles } from '../styles';
|
||||
import { AnnotationsDataFrameViewDTO } from '../types';
|
||||
|
||||
import { AnnotationEditorForm } from './AnnotationEditorForm';
|
||||
|
||||
interface AnnotationEditorProps extends HTMLAttributes<HTMLDivElement> {
|
||||
data: DataFrame;
|
||||
timeZone: TimeZone;
|
||||
selection: PlotSelection;
|
||||
onSave: () => void;
|
||||
onDismiss: () => void;
|
||||
annotation?: AnnotationsDataFrameViewDTO;
|
||||
}
|
||||
|
||||
export const AnnotationEditor = ({
|
||||
onDismiss,
|
||||
onSave,
|
||||
timeZone,
|
||||
data,
|
||||
selection,
|
||||
annotation,
|
||||
style,
|
||||
}: AnnotationEditorProps) => {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles);
|
||||
const commonStyles = useStyles2(getCommonAnnotationStyles);
|
||||
|
||||
// the order of middleware is important!
|
||||
const middleware = [
|
||||
flip({
|
||||
fallbackAxisSideDirection: 'end',
|
||||
// see https://floating-ui.com/docs/flip#combining-with-shift
|
||||
crossAxis: false,
|
||||
boundary: document.body,
|
||||
}),
|
||||
shift(),
|
||||
];
|
||||
|
||||
const { context, refs, floatingStyles } = useFloating({
|
||||
open: true,
|
||||
placement: 'bottom',
|
||||
onOpenChange: (open) => {
|
||||
if (!open) {
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
middleware,
|
||||
whileElementsMounted: autoUpdate,
|
||||
strategy: 'fixed',
|
||||
});
|
||||
|
||||
const dismiss = useDismiss(context);
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
|
||||
|
||||
let xField = data.fields[0];
|
||||
if (!xField) {
|
||||
return null;
|
||||
}
|
||||
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
|
||||
const isRegionAnnotation = selection.min !== selection.max;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<>
|
||||
<div // div overlay matching uPlot canvas bbox
|
||||
style={style}
|
||||
>
|
||||
<div // Annotation marker
|
||||
className={cx(
|
||||
css({
|
||||
position: 'absolute',
|
||||
top: selection.bbox.top,
|
||||
left: selection.bbox.left,
|
||||
width: selection.bbox.width,
|
||||
height: selection.bbox.height,
|
||||
}),
|
||||
isRegionAnnotation ? styles.overlayRange(annotation) : styles.overlay(annotation)
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
className={
|
||||
isRegionAnnotation
|
||||
? cx(commonStyles(annotation).markerBar, styles.markerBar)
|
||||
: cx(commonStyles(annotation).markerTriangle, styles.markerTriangle)
|
||||
}
|
||||
{...getReferenceProps()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnnotationEditorForm
|
||||
annotation={annotation || ({ time: selection.min, timeEnd: selection.max } as AnnotationsDataFrameViewDTO)}
|
||||
timeFormatter={(v) => xFieldFmt(v).text}
|
||||
onSave={onSave}
|
||||
onDismiss={onDismiss}
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
/>
|
||||
</>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
overlay: (annotation?: AnnotationsDataFrameViewDTO) => {
|
||||
const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR);
|
||||
return css({
|
||||
borderLeft: `1px dashed ${color}`,
|
||||
});
|
||||
},
|
||||
overlayRange: (annotation?: AnnotationsDataFrameViewDTO) => {
|
||||
const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR);
|
||||
return css({
|
||||
background: colorManipulator.alpha(color, 0.1),
|
||||
borderLeft: `1px dashed ${color}`,
|
||||
borderRight: `1px dashed ${color}`,
|
||||
});
|
||||
},
|
||||
markerTriangle: css({
|
||||
top: `calc(100% + 2px)`,
|
||||
left: '-4px',
|
||||
position: 'absolute',
|
||||
}),
|
||||
markerBar: css({
|
||||
top: '100%',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
}),
|
||||
};
|
||||
};
|
@ -1,182 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { HTMLAttributes, useRef } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
import useClickAway from 'react-use/lib/useClickAway';
|
||||
|
||||
import { AnnotationEventUIModel, GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, Field, Stack, TextArea, usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { Form } from 'app/core/components/Form/Form';
|
||||
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
|
||||
import { getAnnotationTags } from 'app/features/annotations/api';
|
||||
|
||||
import { AnnotationsDataFrameViewDTO } from '../types';
|
||||
|
||||
interface AnnotationEditFormDTO {
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface AnnotationEditorFormProps extends HTMLAttributes<HTMLDivElement> {
|
||||
annotation: AnnotationsDataFrameViewDTO;
|
||||
timeFormatter: (v: number) => string;
|
||||
onSave: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const AnnotationEditorForm = React.forwardRef<HTMLDivElement, AnnotationEditorFormProps>(
|
||||
({ annotation, onSave, onDismiss, timeFormatter, className, ...otherProps }, ref) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const panelContext = usePanelContext();
|
||||
const clickAwayRef = useRef(null);
|
||||
|
||||
useClickAway(clickAwayRef, () => {
|
||||
onDismiss();
|
||||
});
|
||||
|
||||
const [createAnnotationState, createAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => {
|
||||
const result = await panelContext.onAnnotationCreate!(event);
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const [updateAnnotationState, updateAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => {
|
||||
const result = await panelContext.onAnnotationUpdate!(event);
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const isUpdatingAnnotation = annotation.id !== undefined;
|
||||
const isRegionAnnotation = annotation.time !== annotation.timeEnd;
|
||||
const operation = isUpdatingAnnotation ? updateAnnotation : createAnnotation;
|
||||
const stateIndicator = isUpdatingAnnotation ? updateAnnotationState : createAnnotationState;
|
||||
const ts = isRegionAnnotation
|
||||
? `${timeFormatter(annotation.time)} - ${timeFormatter(annotation.timeEnd)}`
|
||||
: timeFormatter(annotation.time);
|
||||
|
||||
const onSubmit = ({ tags, description }: AnnotationEditFormDTO) => {
|
||||
operation({
|
||||
id: annotation.id,
|
||||
tags,
|
||||
description,
|
||||
from: Math.round(annotation.time!),
|
||||
to: Math.round(annotation.timeEnd!),
|
||||
});
|
||||
};
|
||||
|
||||
const form = (
|
||||
<div // Annotation editor
|
||||
ref={ref}
|
||||
className={cx(styles.editor, className)}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Stack justifyContent={'space-between'} alignItems={'center'}>
|
||||
<div className={styles.title}>Add annotation</div>
|
||||
<div className={styles.ts}>{ts}</div>
|
||||
</Stack>
|
||||
</div>
|
||||
<div className={styles.editorForm}>
|
||||
<Form<AnnotationEditFormDTO>
|
||||
onSubmit={onSubmit}
|
||||
defaultValues={{ description: annotation?.text, tags: annotation?.tags || [] }}
|
||||
>
|
||||
{({ register, errors, control }) => {
|
||||
return (
|
||||
<>
|
||||
<Field label={'Description'} invalid={!!errors.description} error={errors?.description?.message}>
|
||||
<TextArea
|
||||
{...register('description', {
|
||||
required: 'Annotation description is required',
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={'Tags'}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tags"
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<TagFilter
|
||||
allowCustomValue
|
||||
placeholder="Add tags"
|
||||
onChange={onChange}
|
||||
tagOptions={getAnnotationTags}
|
||||
tags={field.value}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Stack justifyContent={'flex-end'}>
|
||||
<Button size={'sm'} variant="secondary" onClick={onDismiss} fill="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size={'sm'} type={'submit'} disabled={stateIndicator?.loading}>
|
||||
{stateIndicator?.loading ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.backdrop} />
|
||||
<div ref={clickAwayRef}>{form}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AnnotationEditorForm.displayName = 'AnnotationEditorForm';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
backdrop: css`
|
||||
label: backdrop;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
z-index: ${theme.zIndex.navbarFixed};
|
||||
`,
|
||||
editorContainer: css`
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
`,
|
||||
editor: css`
|
||||
background: ${theme.colors.background.primary};
|
||||
box-shadow: ${theme.shadows.z3};
|
||||
z-index: ${theme.zIndex.dropdown};
|
||||
border: 1px solid ${theme.colors.border.weak};
|
||||
border-radius: ${theme.shape.radius.default};
|
||||
width: 460px;
|
||||
`,
|
||||
editorForm: css`
|
||||
padding: ${theme.spacing(1)};
|
||||
`,
|
||||
header: css`
|
||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
||||
padding: ${theme.spacing(1.5, 1)};
|
||||
`,
|
||||
title: css`
|
||||
font-weight: ${theme.typography.fontWeightMedium};
|
||||
`,
|
||||
ts: css`
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
};
|
||||
};
|
@ -1,167 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
safePolygon,
|
||||
shift,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useHover,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react';
|
||||
import React, { HTMLAttributes, useCallback, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Portal, useStyles2, usePanelContext } from '@grafana/ui';
|
||||
import { getTooltipContainerStyles } from '@grafana/ui/src/themes/mixins';
|
||||
|
||||
import { getCommonAnnotationStyles } from '../styles';
|
||||
import { AnnotationsDataFrameViewDTO } from '../types';
|
||||
|
||||
import { AnnotationEditorForm } from './AnnotationEditorForm';
|
||||
import { AnnotationTooltip } from './AnnotationTooltip';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
timeZone: TimeZone;
|
||||
annotation: AnnotationsDataFrameViewDTO;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const MIN_REGION_ANNOTATION_WIDTH = 6;
|
||||
|
||||
export function AnnotationMarker({ annotation, timeZone, width }: Props) {
|
||||
const { canEditAnnotations, canDeleteAnnotations, ...panelCtx } = usePanelContext();
|
||||
const commonStyles = useStyles2(getCommonAnnotationStyles);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// the order of middleware is important!
|
||||
const middleware = [
|
||||
flip({
|
||||
fallbackAxisSideDirection: 'end',
|
||||
// see https://floating-ui.com/docs/flip#combining-with-shift
|
||||
crossAxis: false,
|
||||
boundary: document.body,
|
||||
}),
|
||||
shift(),
|
||||
];
|
||||
|
||||
const { context, refs, floatingStyles } = useFloating({
|
||||
open: isOpen,
|
||||
placement: 'bottom',
|
||||
onOpenChange: setIsOpen,
|
||||
middleware,
|
||||
whileElementsMounted: autoUpdate,
|
||||
strategy: 'fixed',
|
||||
});
|
||||
|
||||
const hover = useHover(context, {
|
||||
handleClose: safePolygon(),
|
||||
});
|
||||
const dismiss = useDismiss(context);
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, hover]);
|
||||
|
||||
const onAnnotationEdit = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
setIsOpen(false);
|
||||
}, [setIsEditing, setIsOpen]);
|
||||
|
||||
const onAnnotationDelete = useCallback(() => {
|
||||
if (panelCtx.onAnnotationDelete) {
|
||||
panelCtx.onAnnotationDelete(annotation.id);
|
||||
}
|
||||
}, [annotation, panelCtx]);
|
||||
|
||||
const timeFormatter = useCallback(
|
||||
(value: number) => {
|
||||
return dateTimeFormat(value, {
|
||||
format: systemDateFormats.fullDate,
|
||||
timeZone,
|
||||
});
|
||||
},
|
||||
[timeZone]
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(() => {
|
||||
return (
|
||||
<AnnotationTooltip
|
||||
annotation={annotation}
|
||||
timeFormatter={timeFormatter}
|
||||
onEdit={onAnnotationEdit}
|
||||
onDelete={onAnnotationDelete}
|
||||
canEdit={canEditAnnotations ? canEditAnnotations(annotation.dashboardUID) : false}
|
||||
canDelete={canDeleteAnnotations ? canDeleteAnnotations(annotation.dashboardUID) : false}
|
||||
/>
|
||||
);
|
||||
}, [canEditAnnotations, canDeleteAnnotations, onAnnotationDelete, onAnnotationEdit, timeFormatter, annotation]);
|
||||
|
||||
const isRegionAnnotation = Boolean(annotation.isRegion) && width > MIN_REGION_ANNOTATION_WIDTH;
|
||||
|
||||
let left = `${width / 2}px`;
|
||||
let marker = (
|
||||
<div
|
||||
className={commonStyles(annotation).markerTriangle}
|
||||
style={{ left, position: 'relative', transform: 'translate3d(-100%,-50%, 0)' }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isRegionAnnotation) {
|
||||
marker = (
|
||||
<div
|
||||
className={commonStyles(annotation).markerBar}
|
||||
style={{ width: `${width}px`, transform: 'translate3d(0,-50%, 0)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
className={!isRegionAnnotation ? styles.markerWrapper : undefined}
|
||||
data-testid={selectors.pages.Dashboard.Annotations.marker}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{marker}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<Portal>
|
||||
<div className={styles.tooltip} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
|
||||
{renderTooltip()}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<Portal>
|
||||
<AnnotationEditorForm
|
||||
onDismiss={() => setIsEditing(false)}
|
||||
onSave={() => setIsEditing(false)}
|
||||
timeFormatter={timeFormatter}
|
||||
annotation={annotation}
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
markerWrapper: css({
|
||||
label: 'markerWrapper',
|
||||
padding: theme.spacing(0, 0.5, 0.5, 0.5),
|
||||
}),
|
||||
tooltip: css({
|
||||
...getTooltipContainerStyles(theme),
|
||||
padding: 0,
|
||||
}),
|
||||
};
|
||||
};
|
@ -1,152 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2, textUtil } from '@grafana/data';
|
||||
import { HorizontalGroup, IconButton, Tag, useStyles2 } from '@grafana/ui';
|
||||
import alertDef from 'app/features/alerting/state/alertDef';
|
||||
|
||||
import { AnnotationsDataFrameViewDTO } from '../types';
|
||||
|
||||
interface AnnotationTooltipProps {
|
||||
annotation: AnnotationsDataFrameViewDTO;
|
||||
timeFormatter: (v: number) => string;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const AnnotationTooltip = ({
|
||||
annotation,
|
||||
timeFormatter,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: AnnotationTooltipProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const time = timeFormatter(annotation.time);
|
||||
const timeEnd = timeFormatter(annotation.timeEnd);
|
||||
let text = annotation.text;
|
||||
const tags = annotation.tags;
|
||||
let alertText = '';
|
||||
let avatar;
|
||||
let editControls;
|
||||
let state: React.ReactNode | null = null;
|
||||
|
||||
const ts = <span className={styles.time}>{Boolean(annotation.isRegion) ? `${time} - ${timeEnd}` : time}</span>;
|
||||
|
||||
if (annotation.login && annotation.avatarUrl) {
|
||||
avatar = <img className={styles.avatar} alt="Annotation avatar" src={annotation.avatarUrl} />;
|
||||
}
|
||||
|
||||
if (annotation.alertId !== undefined && annotation.newState) {
|
||||
const stateModel = alertDef.getStateDisplayModel(annotation.newState);
|
||||
state = (
|
||||
<div className={styles.alertState}>
|
||||
<i className={stateModel.stateClass}>{stateModel.text}</i>
|
||||
</div>
|
||||
);
|
||||
|
||||
alertText = alertDef.getAlertAnnotationInfo(annotation);
|
||||
} else if (annotation.title) {
|
||||
text = annotation.title + '<br />' + (typeof text === 'string' ? text : '');
|
||||
}
|
||||
|
||||
if (canEdit || canDelete) {
|
||||
editControls = (
|
||||
<div className={styles.editControls}>
|
||||
{canEdit && <IconButton name={'pen'} size={'sm'} onClick={onEdit} tooltip="Edit" />}
|
||||
{canDelete && <IconButton name={'trash-alt'} size={'sm'} onClick={onDelete} tooltip="Delete" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
<HorizontalGroup justify={'space-between'} align={'center'} spacing={'md'}>
|
||||
<div className={styles.meta}>
|
||||
<span>
|
||||
{avatar}
|
||||
{state}
|
||||
</span>
|
||||
{ts}
|
||||
</div>
|
||||
{editControls}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{text && <div dangerouslySetInnerHTML={{ __html: textUtil.sanitize(text) }} />}
|
||||
{alertText}
|
||||
<>
|
||||
<HorizontalGroup spacing="xs" wrap>
|
||||
{tags?.map((t, i) => <Tag name={t} key={`${t}-${i}`} />)}
|
||||
</HorizontalGroup>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AnnotationTooltip.displayName = 'AnnotationTooltip';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
wrapper: css`
|
||||
max-width: 400px;
|
||||
`,
|
||||
commentWrapper: css`
|
||||
margin-top: 10px;
|
||||
border-top: 2px solid #2d2b34;
|
||||
height: 30vh;
|
||||
overflow-y: scroll;
|
||||
padding: 0 3px;
|
||||
`,
|
||||
header: css`
|
||||
padding: ${theme.spacing(0.5, 1)};
|
||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
display: flex;
|
||||
`,
|
||||
meta: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`,
|
||||
editControls: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
> :last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`,
|
||||
avatar: css`
|
||||
border-radius: ${theme.shape.radius.circle};
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: ${theme.spacing(1)};
|
||||
`,
|
||||
alertState: css`
|
||||
padding-right: ${theme.spacing(1)};
|
||||
font-weight: ${theme.typography.fontWeightMedium};
|
||||
`,
|
||||
time: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
`,
|
||||
body: css`
|
||||
padding: ${theme.spacing(1)};
|
||||
|
||||
a {
|
||||
color: ${theme.colors.text.link};
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
@ -5,6 +5,7 @@ import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { TimeZone } from '@grafana/schema';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
@ -80,6 +81,7 @@ export const AnnotationMarker2 = ({
|
||||
style={style!}
|
||||
onMouseEnter={() => state !== STATE_EDITING && setState(STATE_HOVERED)}
|
||||
onMouseLeave={() => state !== STATE_EDITING && setState(STATE_DEFAULT)}
|
||||
data-testid={selectors.pages.Dashboard.Annotations.marker}
|
||||
>
|
||||
{contents &&
|
||||
createPortal(
|
||||
|
@ -1,27 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
|
||||
|
||||
import { AnnotationsDataFrameViewDTO } from './types';
|
||||
|
||||
export const getCommonAnnotationStyles = (theme: GrafanaTheme2) => {
|
||||
return (annotation?: AnnotationsDataFrameViewDTO) => {
|
||||
const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR);
|
||||
return {
|
||||
markerTriangle: css`
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 4px solid ${color};
|
||||
`,
|
||||
markerBar: css`
|
||||
display: block;
|
||||
width: calc(100%);
|
||||
height: 5px;
|
||||
background: ${color};
|
||||
`,
|
||||
};
|
||||
};
|
||||
};
|
@ -14,6 +14,8 @@ import { nullToValue } from '@grafana/data/src/transformations/transformers/null
|
||||
import { GraphFieldConfig, LineInterpolation, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
|
||||
import { buildScaleKey } from '@grafana/ui/src/components/uPlot/internal';
|
||||
|
||||
import { HeatmapTooltip } from '../heatmap/panelcfg.gen';
|
||||
|
||||
type ScaleKey = string;
|
||||
|
||||
// this will re-enumerate all enum fields on the same scale to create one ordinal progression
|
||||
@ -263,6 +265,6 @@ export function getTimezones(timezones: string[] | undefined, defaultTimezone: s
|
||||
return timezones.map((v) => (v?.length ? v : defaultTimezone));
|
||||
}
|
||||
|
||||
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions) => {
|
||||
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions | HeatmapTooltip) => {
|
||||
return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null;
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import React, { useMemo } from 'react';
|
||||
import { DataFrame, FieldMatcherID, fieldMatchers, FieldType, PanelProps, TimeRange } from '@grafana/data';
|
||||
import { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames';
|
||||
import { config, PanelDataErrorView } from '@grafana/runtime';
|
||||
import { KeyboardPlugin, TooltipDisplayMode, usePanelContext, TooltipPlugin, TooltipPlugin2 } from '@grafana/ui';
|
||||
import { KeyboardPlugin, TooltipDisplayMode, usePanelContext, TooltipPlugin2 } from '@grafana/ui';
|
||||
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
|
||||
import { XYFieldMatchers } from 'app/core/components/GraphNG/types';
|
||||
import { preparePlotFrame } from 'app/core/components/GraphNG/utils';
|
||||
@ -26,8 +26,6 @@ export const TrendPanel = ({
|
||||
replaceVariables,
|
||||
id,
|
||||
}: PanelProps<Options>) => {
|
||||
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
|
||||
|
||||
const { dataLinkPostProcessor } = usePanelContext();
|
||||
// Need to fallback to first number field if no xField is set in options otherwise panel crashes 😬
|
||||
const trendXFieldName =
|
||||
@ -117,41 +115,28 @@ export const TrendPanel = ({
|
||||
<>
|
||||
<KeyboardPlugin config={uPlotConfig} />
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<>
|
||||
{showNewVizTooltips ? (
|
||||
<TooltipPlugin2
|
||||
config={uPlotConfig}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
|
||||
}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned = false) => {
|
||||
return (
|
||||
<TimeSeriesTooltip
|
||||
frames={info.frames!}
|
||||
seriesFrame={alignedDataFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
isPinned={isPinned}
|
||||
scrollable={isTooltipScrollable(options.tooltip)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
) : (
|
||||
<TooltipPlugin
|
||||
frames={info.frames!}
|
||||
data={alignedDataFrame}
|
||||
config={uPlotConfig}
|
||||
mode={options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<TooltipPlugin2
|
||||
config={uPlotConfig}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
|
||||
}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned = false) => {
|
||||
return (
|
||||
<TimeSeriesTooltip
|
||||
frames={info.frames!}
|
||||
seriesFrame={alignedDataFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
isPinned={isPinned}
|
||||
scrollable={isTooltipScrollable(options.tooltip)}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -1,184 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { DataFrame, Field, formattedValueToString, getFieldDisplayName, GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import { LinkButton, useStyles2, VerticalGroup, VizTooltipOptions } from '@grafana/ui';
|
||||
import { findField } from 'app/features/dimensions';
|
||||
import { getTitleFromHref } from 'app/features/explore/utils/links';
|
||||
|
||||
import { ScatterSeriesConfig, SeriesMapping } from './panelcfg.gen';
|
||||
import { ScatterSeries } from './types';
|
||||
|
||||
interface YValue {
|
||||
name: string;
|
||||
val: number;
|
||||
field: Field;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ExtraFacets {
|
||||
colorFacetFieldName: string;
|
||||
sizeFacetFieldName: string;
|
||||
colorFacetValue: number;
|
||||
sizeFacetValue: number;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
allSeries: ScatterSeries[];
|
||||
data: DataFrame[]; // source data
|
||||
manualSeriesConfigs: ScatterSeriesConfig[] | undefined;
|
||||
rowIndex?: number; // the hover row
|
||||
seriesMapping: SeriesMapping;
|
||||
hoveredPointIndex: number; // the hovered point
|
||||
options: VizTooltipOptions;
|
||||
}
|
||||
|
||||
export const TooltipView = ({
|
||||
allSeries,
|
||||
data,
|
||||
manualSeriesConfigs,
|
||||
seriesMapping,
|
||||
rowIndex,
|
||||
hoveredPointIndex,
|
||||
options,
|
||||
}: Props) => {
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
if (!allSeries || rowIndex == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const series = allSeries[hoveredPointIndex];
|
||||
const frame = series.frame(data);
|
||||
const xField = series.x(frame);
|
||||
const yField = series.y(frame);
|
||||
|
||||
let links: LinkModel[] | undefined = undefined;
|
||||
|
||||
if (yField.getLinks) {
|
||||
const v = yField.values[rowIndex];
|
||||
const disp = yField.display ? yField.display(v) : { text: `${v}`, numeric: +v };
|
||||
links = yField.getLinks({ calculatedValue: disp, valueRowIndex: rowIndex }).map((linkModel) => {
|
||||
if (!linkModel.title) {
|
||||
linkModel.title = getTitleFromHref(linkModel.href);
|
||||
}
|
||||
|
||||
return linkModel;
|
||||
});
|
||||
}
|
||||
|
||||
let extraFields: Field[] = frame.fields.filter((f) => f !== xField && f !== yField);
|
||||
|
||||
let yValue: YValue | null = null;
|
||||
let extraFacets: ExtraFacets | null = null;
|
||||
if (seriesMapping === SeriesMapping.Manual && manualSeriesConfigs) {
|
||||
const colorFacetFieldName = manualSeriesConfigs[hoveredPointIndex]?.pointColor?.field ?? '';
|
||||
const sizeFacetFieldName = manualSeriesConfigs[hoveredPointIndex]?.pointSize?.field ?? '';
|
||||
|
||||
const colorFacet = colorFacetFieldName ? findField(frame, colorFacetFieldName) : undefined;
|
||||
const sizeFacet = sizeFacetFieldName ? findField(frame, sizeFacetFieldName) : undefined;
|
||||
|
||||
extraFacets = {
|
||||
colorFacetFieldName,
|
||||
sizeFacetFieldName,
|
||||
colorFacetValue: colorFacet?.values[rowIndex],
|
||||
sizeFacetValue: sizeFacet?.values[rowIndex],
|
||||
};
|
||||
|
||||
extraFields = extraFields.filter((f) => f !== colorFacet && f !== sizeFacet);
|
||||
}
|
||||
|
||||
yValue = {
|
||||
name: getFieldDisplayName(yField, frame),
|
||||
val: yField.values[rowIndex],
|
||||
field: yField,
|
||||
color: series.pointColor(frame) as string,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<table className={style.infoWrap}>
|
||||
<tr>
|
||||
<th colSpan={2} style={{ backgroundColor: yValue.color }}></th>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{getFieldDisplayName(xField, frame)}</th>
|
||||
<td>{fmt(xField, xField.values[rowIndex])}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{yValue.name}:</th>
|
||||
<td>{fmt(yValue.field, yValue.val)}</td>
|
||||
</tr>
|
||||
{extraFacets !== null && extraFacets.colorFacetFieldName && (
|
||||
<tr>
|
||||
<th>{extraFacets.colorFacetFieldName}:</th>
|
||||
<td>{extraFacets.colorFacetValue}</td>
|
||||
</tr>
|
||||
)}
|
||||
{extraFacets !== null && extraFacets.sizeFacetFieldName && (
|
||||
<tr>
|
||||
<th>{extraFacets.sizeFacetFieldName}:</th>
|
||||
<td>{extraFacets.sizeFacetValue}</td>
|
||||
</tr>
|
||||
)}
|
||||
{extraFields.map((field, i) => (
|
||||
<tr key={i}>
|
||||
<th>{getFieldDisplayName(field, frame)}:</th>
|
||||
<td>{fmt(field, field.values[rowIndex])}</td>
|
||||
</tr>
|
||||
))}
|
||||
{links && links.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<VerticalGroup>
|
||||
{links.map((link, i) => (
|
||||
<LinkButton
|
||||
key={i}
|
||||
icon={'external-link-alt'}
|
||||
target={link.target}
|
||||
href={link.href}
|
||||
onClick={link.onClick}
|
||||
fill="text"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{link.title}
|
||||
</LinkButton>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function fmt(field: Field, val: number): string {
|
||||
if (field.display) {
|
||||
return formattedValueToString(field.display(val));
|
||||
}
|
||||
return `${val}`;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
infoWrap: css({
|
||||
padding: '8px',
|
||||
width: '100%',
|
||||
th: {
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
padding: theme.spacing(0.25, 2),
|
||||
},
|
||||
}),
|
||||
highlight: css({
|
||||
background: theme.colors.action.hover,
|
||||
}),
|
||||
xVal: css({
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
}),
|
||||
icon: css({
|
||||
marginRight: theme.spacing(1),
|
||||
verticalAlign: 'middle',
|
||||
}),
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
|
||||
import {
|
||||
@ -14,7 +14,6 @@ import {
|
||||
import { alpha } from '@grafana/data/src/themes/colorManipulator';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
Portal,
|
||||
TooltipDisplayMode,
|
||||
TooltipPlugin2,
|
||||
UPlotChart,
|
||||
@ -22,61 +21,29 @@ import {
|
||||
VizLayout,
|
||||
VizLegend,
|
||||
VizLegendItem,
|
||||
VizTooltipContainer,
|
||||
} from '@grafana/ui';
|
||||
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
|
||||
import { FacetedData } from '@grafana/ui/src/components/uPlot/types';
|
||||
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
|
||||
|
||||
import { TooltipView } from './TooltipView';
|
||||
import { XYChartTooltip } from './XYChartTooltip';
|
||||
import { Options, SeriesMapping } from './panelcfg.gen';
|
||||
import { prepData, prepScatter, ScatterPanelInfo } from './scatter';
|
||||
import { ScatterHoverEvent, ScatterSeries } from './types';
|
||||
import { ScatterSeries } from './types';
|
||||
|
||||
type Props = PanelProps<Options>;
|
||||
const TOOLTIP_OFFSET = 10;
|
||||
|
||||
export const XYChartPanel = (props: Props) => {
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [series, setSeries] = useState<ScatterSeries[]>([]);
|
||||
const [builder, setBuilder] = useState<UPlotConfigBuilder | undefined>();
|
||||
const [facets, setFacets] = useState<FacetedData | undefined>();
|
||||
const [hover, setHover] = useState<ScatterHoverEvent | undefined>();
|
||||
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
|
||||
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
|
||||
|
||||
const isToolTipOpen = useRef<boolean>(false);
|
||||
const oldOptions = usePrevious(props.options);
|
||||
const oldData = usePrevious(props.data);
|
||||
|
||||
const onCloseToolTip = () => {
|
||||
isToolTipOpen.current = false;
|
||||
setShouldDisplayCloseButton(false);
|
||||
scatterHoverCallback(undefined);
|
||||
};
|
||||
|
||||
const onUPlotClick = () => {
|
||||
isToolTipOpen.current = !isToolTipOpen.current;
|
||||
|
||||
// Linking into useState required to re-render tooltip
|
||||
setShouldDisplayCloseButton(isToolTipOpen.current);
|
||||
};
|
||||
|
||||
const scatterHoverCallback = (hover?: ScatterHoverEvent) => {
|
||||
setHover(hover);
|
||||
};
|
||||
|
||||
const initSeries = useCallback(() => {
|
||||
const getData = () => props.data.series;
|
||||
const info: ScatterPanelInfo = prepScatter(
|
||||
props.options,
|
||||
getData,
|
||||
config.theme2,
|
||||
showNewVizTooltips ? null : scatterHoverCallback,
|
||||
showNewVizTooltips ? null : onUPlotClick,
|
||||
showNewVizTooltips ? null : isToolTipOpen
|
||||
);
|
||||
const info: ScatterPanelInfo = prepScatter(props.options, getData, config.theme2);
|
||||
|
||||
if (info.error) {
|
||||
setError(info.error);
|
||||
@ -86,7 +53,7 @@ export const XYChartPanel = (props: Props) => {
|
||||
setFacets(() => prepData(info, props.data.series));
|
||||
setError(undefined);
|
||||
}
|
||||
}, [props.data.series, props.options, showNewVizTooltips]);
|
||||
}, [props.data.series, props.options]);
|
||||
|
||||
const initFacets = useCallback(() => {
|
||||
setFacets(() => prepData({ error, series }, props.data.series));
|
||||
@ -223,7 +190,7 @@ export const XYChartPanel = (props: Props) => {
|
||||
<VizLayout width={props.width} height={props.height} legend={renderLegend()}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
<UPlotChart config={builder} data={facets} width={vizWidth} height={vizHeight}>
|
||||
{showNewVizTooltips && props.options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
{props.options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={builder}
|
||||
hoverMode={TooltipHoverMode.xyOne}
|
||||
@ -241,52 +208,11 @@ export const XYChartPanel = (props: Props) => {
|
||||
);
|
||||
}}
|
||||
maxWidth={props.options.tooltip.maxWidth}
|
||||
maxHeight={props.options.tooltip.maxHeight}
|
||||
/>
|
||||
)}
|
||||
</UPlotChart>
|
||||
)}
|
||||
</VizLayout>
|
||||
{!showNewVizTooltips && (
|
||||
<Portal>
|
||||
{hover && props.options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: hover.pageX, y: hover.pageY }}
|
||||
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{shouldDisplayCloseButton && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CloseButton
|
||||
onClick={onCloseToolTip}
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TooltipView
|
||||
options={props.options.tooltip}
|
||||
allSeries={series}
|
||||
manualSeriesConfigs={props.options.series}
|
||||
seriesMapping={props.options.seriesMapping!}
|
||||
rowIndex={hover.xIndex}
|
||||
hoveredPointIndex={hover.scatterIndex}
|
||||
data={props.data.series}
|
||||
/>
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
@ -31,7 +30,7 @@ import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
|
||||
import { DEFAULT_POINT_SIZE } from './config';
|
||||
import { isGraphable } from './dims';
|
||||
import { FieldConfig, defaultFieldConfig, Options, ScatterShow } from './panelcfg.gen';
|
||||
import { DimensionValues, ScatterHoverCallback, ScatterSeries } from './types';
|
||||
import { DimensionValues, ScatterSeries } from './types';
|
||||
|
||||
export interface ScatterPanelInfo {
|
||||
error?: string;
|
||||
@ -42,20 +41,13 @@ export interface ScatterPanelInfo {
|
||||
/**
|
||||
* This is called when options or structure rev changes
|
||||
*/
|
||||
export function prepScatter(
|
||||
options: Options,
|
||||
getData: () => DataFrame[],
|
||||
theme: GrafanaTheme2,
|
||||
ttip: null | ScatterHoverCallback,
|
||||
onUPlotClick: null | ((evt?: Object) => void),
|
||||
isToolTipOpen: null | MutableRefObject<boolean>
|
||||
): ScatterPanelInfo {
|
||||
export function prepScatter(options: Options, getData: () => DataFrame[], theme: GrafanaTheme2): ScatterPanelInfo {
|
||||
let series: ScatterSeries[];
|
||||
let builder: UPlotConfigBuilder;
|
||||
|
||||
try {
|
||||
series = prepSeries(options, getData());
|
||||
builder = prepConfig(getData, series, theme, ttip, onUPlotClick, isToolTipOpen);
|
||||
builder = prepConfig(getData, series, theme);
|
||||
} catch (e) {
|
||||
let errorMsg = 'Unknown error in prepScatter';
|
||||
if (typeof e === 'string') {
|
||||
@ -298,14 +290,7 @@ interface DrawBubblesOpts {
|
||||
};
|
||||
}
|
||||
|
||||
const prepConfig = (
|
||||
getData: () => DataFrame[],
|
||||
scatterSeries: ScatterSeries[],
|
||||
theme: GrafanaTheme2,
|
||||
ttip: null | ScatterHoverCallback,
|
||||
onUPlotClick: null | ((evt?: Object) => void),
|
||||
isToolTipOpen: null | MutableRefObject<boolean>
|
||||
) => {
|
||||
const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], theme: GrafanaTheme2) => {
|
||||
let qt: Quadtree;
|
||||
let hRect: Rect | null;
|
||||
|
||||
@ -524,73 +509,7 @@ const prepConfig = (
|
||||
},
|
||||
});
|
||||
|
||||
const clearPopupIfOpened = () => {
|
||||
if (isToolTipOpen?.current) {
|
||||
if (ttip) {
|
||||
ttip(undefined);
|
||||
}
|
||||
if (onUPlotClick) {
|
||||
onUPlotClick();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let ref_parent: HTMLElement | null = null;
|
||||
|
||||
// clip hover points/bubbles to plotting area
|
||||
builder.addHook('init', (u, r) => {
|
||||
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
|
||||
|
||||
if (!showNewVizTooltips) {
|
||||
u.over.style.overflow = 'hidden';
|
||||
}
|
||||
ref_parent = u.root.parentElement;
|
||||
|
||||
if (onUPlotClick) {
|
||||
ref_parent?.addEventListener('click', onUPlotClick);
|
||||
}
|
||||
});
|
||||
|
||||
builder.addHook('destroy', (u) => {
|
||||
if (onUPlotClick) {
|
||||
ref_parent?.removeEventListener('click', onUPlotClick);
|
||||
clearPopupIfOpened();
|
||||
}
|
||||
});
|
||||
|
||||
let rect: DOMRect;
|
||||
|
||||
// rect of .u-over (grid area)
|
||||
builder.addHook('syncRect', (u, r) => {
|
||||
rect = r;
|
||||
});
|
||||
|
||||
if (ttip) {
|
||||
builder.addHook('setLegend', (u) => {
|
||||
if (u.cursor.idxs != null) {
|
||||
for (let i = 0; i < u.cursor.idxs.length; i++) {
|
||||
const sel = u.cursor.idxs[i];
|
||||
if (sel != null && !isToolTipOpen?.current) {
|
||||
ttip({
|
||||
scatterIndex: i - 1,
|
||||
xIndex: sel,
|
||||
pageX: rect.left + u.cursor.left!,
|
||||
pageY: rect.top + u.cursor.top!,
|
||||
});
|
||||
return; // only show the first one
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isToolTipOpen?.current) {
|
||||
ttip(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
builder.addHook('drawClear', (u) => {
|
||||
clearPopupIfOpened();
|
||||
|
||||
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
|
||||
|
||||
qt.clear();
|
||||
|
@ -7,15 +7,6 @@ import { VizLegendItem } from '@grafana/ui';
|
||||
*/
|
||||
export type DimensionValues<T> = (frame: DataFrame, from?: number) => T | T[];
|
||||
|
||||
export interface ScatterHoverEvent {
|
||||
scatterIndex: number;
|
||||
xIndex: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}
|
||||
|
||||
export type ScatterHoverCallback = (evt?: ScatterHoverEvent) => void;
|
||||
|
||||
// Using field where we will need formatting/scale/axis info
|
||||
// Use raw or DimensionValues when the values can be used directly
|
||||
export interface ScatterSeries {
|
||||
@ -50,14 +41,3 @@ export interface ScatterSeries {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtraFacets {
|
||||
colorFacetFieldName: string;
|
||||
sizeFacetFieldName: string;
|
||||
colorFacetValue: number;
|
||||
sizeFacetValue: number;
|
||||
}
|
||||
|
||||
export interface DataFilterBySeries {
|
||||
frame: number;
|
||||
}
|
||||
|
@ -112,7 +112,6 @@ export const XYChartPanel2 = (props: Props2) => {
|
||||
);
|
||||
}}
|
||||
maxWidth={props.options.tooltip.maxWidth}
|
||||
maxHeight={props.options.tooltip.maxHeight}
|
||||
/>
|
||||
)}
|
||||
</UPlotChart>
|
||||
|
Loading…
Reference in New Issue
Block a user