BarChart: Refactor and VizTooltip fixes (#87160)

Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
This commit is contained in:
Leon Sorokin 2024-05-10 12:58:53 -05:00 committed by GitHub
parent 8bfd7e5106
commit f43ed7e6d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 618 additions and 607 deletions

View File

@ -4484,22 +4484,18 @@ exports[`better eslint`] = {
"public/app/plugins/panel/annolist/AnnoListPanel.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/barchart/BarChartPanel.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/plugins/panel/barchart/TickSpacingEditor.tsx:5381": [
[0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
],
"public/app/plugins/panel/barchart/bars.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/barchart/module.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/barchart/quadtree.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/barchart/utils.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/candlestick/CandlestickPanel.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]

View File

@ -88,7 +88,7 @@
"id": "links",
"value": [
{
"title": "google",
"title": "${__data.fields.id}/${__field.name}/${__value.raw}",
"url": "google.com"
},
{
@ -114,8 +114,8 @@
"id": "links",
"value": [
{
"title": "datalink column 2",
"url": "grafana.com"
"title": "${__data.fields.id}/${__field.name}/${__value.raw}",
"url": ""
}
]
},
@ -906,6 +906,6 @@
"timezone": "",
"title": "Panel Tests - Bar Chart Tooltips & Legends",
"uid": "ea33320b-bd97-4fe1-a27c-24bc61a48b41",
"version": 1,
"version": 5,
"weekStart": ""
}

View File

@ -1,4 +1,4 @@
import { FALLBACK_COLOR, Field, FieldType, formattedValueToString } from '@grafana/data';
import { FALLBACK_COLOR, Field, FieldType, formattedValueToString, getFieldColorModeForField } from '@grafana/data';
import { SortOrder, TooltipDisplayMode } from '@grafana/schema';
import { ColorIndicatorStyles } from './VizTooltipColorIndicator';
@ -131,12 +131,22 @@ export const getContentItems = (
? Number.MIN_SAFE_INTEGER
: Number.MAX_SAFE_INTEGER;
const colorMode = getFieldColorModeForField(field);
let colorIndicator = ColorIndicator.series;
let colorPlacement = ColorPlacement.first;
if (colorMode.isByValue) {
colorIndicator = ColorIndicator.value;
colorPlacement = ColorPlacement.trailing;
}
rows.push({
label: field.state?.displayName ?? field.name,
value: formattedValueToString(display),
color: display.color ?? FALLBACK_COLOR,
colorIndicator: ColorIndicator.series,
colorPlacement: ColorPlacement.first,
colorIndicator,
colorPlacement,
isActive: mode === TooltipDisplayMode.Multi && seriesIdx === i,
numeric,
});

View File

@ -0,0 +1,93 @@
import React from 'react';
import { DataFrame, Field, getFieldSeriesColor } from '@grafana/data';
import { VizLegendOptions, AxisPlacement } from '@grafana/schema';
import { UPlotConfigBuilder, VizLayout, VizLayoutLegendProps, VizLegend, VizLegendItem, useTheme2 } from '@grafana/ui';
import { getDisplayValuesForCalcs } from '@grafana/ui/src/components/uPlot/utils';
import { getFieldLegendItem } from 'app/core/components/TimelineChart/utils';
interface BarChartLegend2Props extends VizLegendOptions, Omit<VizLayoutLegendProps, 'children'> {
data: DataFrame[];
colorField?: Field | null;
// config: UPlotConfigBuilder;
}
/**
* mostly duplicates logic in PlotLegend below :(
*
* @internal
*/
export function hasVisibleLegendSeries(config: UPlotConfigBuilder, data: DataFrame[]) {
return data[0].fields.slice(1).some((field) => !Boolean(field.config.custom?.hideFrom?.legend));
// return config.getSeries().some((s, i) => {
// const frameIndex = 0;
// const fieldIndex = i + 1;
// const field = data[frameIndex].fields[fieldIndex];
// return !Boolean(field.config.custom?.hideFrom?.legend);
// });
}
export const BarChartLegend = React.memo(
({ data, placement, calcs, displayMode, colorField, ...vizLayoutLegendProps }: BarChartLegend2Props) => {
const theme = useTheme2();
if (colorField != null) {
const items = getFieldLegendItem([colorField], theme);
if (items?.length) {
return (
<VizLayout.Legend placement={placement}>
<VizLegend placement={placement} items={items} displayMode={displayMode} />
</VizLayout.Legend>
);
}
}
const legendItems = data[0].fields
.slice(1)
.map((field, i) => {
const frameIndex = 0;
const fieldIndex = i + 1;
// const axisPlacement = config.getAxisPlacement(s.props.scaleKey); // TODO: this should be stamped on the field.config?
// const field = data[frameIndex].fields[fieldIndex];
if (!field || field.config.custom?.hideFrom?.legend) {
return undefined;
}
// // apparently doing a second pass like this will take existing state.displayName, and if same as another one, appends counter
// const label = getFieldDisplayName(field, data[0], data);
const label = field.state?.displayName ?? field.name;
const color = getFieldSeriesColor(field, theme).color;
const item: VizLegendItem = {
disabled: field.state?.hideFrom?.viz,
color,
label,
yAxis: field.config.custom?.axisPlacement === AxisPlacement.Right ? 2 : 1,
getDisplayValues: () => getDisplayValuesForCalcs(calcs, field, theme),
getItemKey: () => `${label}-${frameIndex}-${fieldIndex}`,
};
return item;
})
.filter((i): i is VizLegendItem => i !== undefined);
return (
<VizLayout.Legend placement={placement} {...vizLayoutLegendProps}>
<VizLegend
placement={placement}
items={legendItems}
displayMode={displayMode}
sortBy={vizLayoutLegendProps.sortBy}
sortDesc={vizLayoutLegendProps.sortDesc}
isSortable={true}
/>
</VizLayout.Legend>
);
}
);
BarChartLegend.displayName = 'BarChartLegend';

View File

@ -1,117 +1,134 @@
import React, { useMemo, useRef } from 'react';
import React, { useMemo } from 'react';
import {
compareDataFrameStructures,
DataFrame,
Field,
FieldColorModeId,
FieldType,
PanelProps,
TimeRange,
VizOrientation,
} from '@grafana/data';
import { PanelProps, VizOrientation } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import {
GraphGradientMode,
measureText,
PlotLegend,
TooltipDisplayMode,
UPlotConfigBuilder,
UPLOT_AXIS_FONT_SIZE,
usePanelContext,
useTheme2,
VizLayout,
VizLegend,
TooltipPlugin2,
UPLOT_AXIS_FONT_SIZE,
UPlotChart,
VizLayout,
measureText,
// usePanelContext,
useTheme2,
} from '@grafana/ui';
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { GraphNG, GraphNGProps, PropDiffFn } from 'app/core/components/GraphNG/GraphNG';
import { getFieldLegendItem } from 'app/core/components/TimelineChart/utils';
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
import { isTooltipScrollable } from '../timeseries/utils';
import { BarChartLegend, hasVisibleLegendSeries } from './BarChartLegend';
import { Options } from './panelcfg.gen';
import { prepareBarChartDisplayValues, preparePlotConfigBuilder } from './utils';
import { prepConfig, prepSeries } from './utils';
/**
* @alpha
*/
export interface BarChartProps
extends Options,
Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend' | 'theme'> {}
const charWidth = measureText('M', UPLOT_AXIS_FONT_SIZE).width;
const toRads = Math.PI / 180;
const propsToDiff: Array<string | PropDiffFn> = [
'orientation',
'barWidth',
'barRadius',
'xTickLabelRotation',
'xTickLabelMaxLength',
'xTickLabelSpacing',
'groupWidth',
'stacking',
'showValue',
'xField',
'colorField',
'legend',
(prev: BarChartProps, next: BarChartProps) => next.text?.valueSize === prev.text?.valueSize,
];
export const BarChartPanel = (props: PanelProps<Options>) => {
const {
data,
options,
fieldConfig,
width,
height,
timeZone,
id,
// replaceVariables
} = props;
interface Props extends PanelProps<Options> {}
// will need this if joining on time to re-create data links
// const { dataLinkPostProcessor } = usePanelContext();
export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZone, id, replaceVariables }: Props) => {
const theme = useTheme2();
const { dataLinkPostProcessor } = usePanelContext();
const frame0Ref = useRef<DataFrame>();
const colorByFieldRef = useRef<Field>();
const {
barWidth,
barRadius = 0,
showValue,
groupWidth,
stacking,
legend,
tooltip,
text,
xTickLabelRotation,
xTickLabelSpacing,
fullHighlight,
xField,
colorByField,
} = options;
const info = useMemo(() => prepareBarChartDisplayValues(data.series, theme, options), [data.series, theme, options]);
const chartDisplay = 'viz' in info ? info : null;
// size-dependent, calculated opts that should cause viz re-config
let { orientation, xTickLabelMaxLength = 0 } = options;
colorByFieldRef.current = chartDisplay?.colorByField;
orientation =
orientation === VizOrientation.Auto
? width < height
? VizOrientation.Horizontal
: VizOrientation.Vertical
: orientation;
const structureRef = useRef(10000);
// TODO: this can be moved into axis calc internally, no need to re-config based on this
// should be based on vizHeight, not full height?
xTickLabelMaxLength =
xTickLabelRotation === 0
? Infinity // should this calc using spacing between groups?
: xTickLabelMaxLength ||
// auto max length clamps to half viz height, subracts 3 chars for ... ellipsis
Math.floor(height / 2 / Math.sin(Math.abs(xTickLabelRotation * toRads)) / charWidth - 3);
useMemo(() => {
structureRef.current++;
// TODO: config data links
const info = useMemo(
() => prepSeries(data.series, fieldConfig, stacking, theme, xField, colorByField),
[data.series, fieldConfig, stacking, theme, xField, colorByField]
);
const vizSeries = useMemo(
() => [
{
...info.series![0],
fields: info.series![0].fields.filter((field, i) => i === 0 || !field.state?.hideFrom?.viz),
},
],
[info.series]
);
const xGroupsCount = vizSeries[0].length;
const seriesCount = vizSeries[0].fields?.length;
let { builder, prepData } = useMemo(
() => {
return prepConfig({ series: vizSeries, color: info.color, orientation, options, timeZone, theme });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]); // change every time the options object changes (while editing)
[
orientation,
timeZone,
props.data.structureRev,
const structureRev = useMemo(() => {
const f0 = chartDisplay?.viz[0];
const f1 = frame0Ref.current;
if (!(f0 && f1 && compareDataFrameStructures(f0, f1, true))) {
structureRef.current++;
}
frame0Ref.current = f0;
return (data.structureRev ?? 0) + structureRef.current;
}, [chartDisplay, data.structureRev]);
seriesCount,
xGroupsCount,
const orientation = useMemo(() => {
if (!options.orientation || options.orientation === VizOrientation.Auto) {
return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;
}
return options.orientation;
}, [width, height, options.orientation]);
barWidth,
barRadius,
showValue,
groupWidth,
stacking,
legend,
tooltip,
text?.valueSize, // cause text obj is re-created each time?
xTickLabelRotation,
xTickLabelSpacing,
fullHighlight,
xField,
colorByField,
xTickLabelMaxLength, // maybe not?
// props.fieldConfig, // usePrevious hideFrom on all fields?
]
);
const xTickLabelMaxLength = useMemo(() => {
// If no max length is set, limit the number of characters to a length where it will use a maximum of half of the height of the viz.
if (!options.xTickLabelMaxLength) {
const rotationAngle = options.xTickLabelRotation;
const textSize = measureText('M', UPLOT_AXIS_FONT_SIZE).width; // M is usually the widest character so let's use that as an approximation.
const maxHeightForValues = height / 2;
const plotData = useMemo(() => prepData(vizSeries, info.color), [prepData, vizSeries, info.color]);
return (
maxHeightForValues /
(Math.sin(((rotationAngle >= 0 ? rotationAngle : rotationAngle * -1) * Math.PI) / 180) * textSize) -
3 //Subtract 3 for the "..." added to the end.
);
} else {
return options.xTickLabelMaxLength;
}
}, [height, options.xTickLabelRotation, options.xTickLabelMaxLength]);
if ('warn' in info) {
if (info.warn != null) {
return (
<PanelDataErrorView
panelId={id}
@ -123,161 +140,46 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
);
}
const renderLegend = (config: UPlotConfigBuilder) => {
const { legend } = options;
if (!config || legend.showLegend === false) {
return null;
}
if (info.colorByField) {
const items = getFieldLegendItem([info.colorByField], theme);
if (items?.length) {
return (
<VizLayout.Legend placement={legend.placement}>
<VizLegend placement={legend.placement} items={items} displayMode={legend.displayMode} />
</VizLayout.Legend>
);
}
}
return <PlotLegend data={[info.legend]} config={config} maxHeight="35%" maxWidth="60%" {...options.legend} />;
};
const rawValue = (seriesIdx: number, valueIdx: number) => {
return frame0Ref.current!.fields[seriesIdx].values[valueIdx];
};
// Color by value
let getColor: ((seriesIdx: number, valueIdx: number) => string) | undefined = undefined;
let fillOpacity = 1;
if (info.colorByField) {
const colorByField = info.colorByField;
const disp = colorByField.display!;
fillOpacity = (colorByField.config.custom.fillOpacity ?? 100) / 100;
// gradientMode? ignore?
getColor = (seriesIdx: number, valueIdx: number) => disp(colorByFieldRef.current?.values[valueIdx]).color!;
} else {
const hasPerBarColor = frame0Ref.current!.fields.some((f) => {
const fromThresholds =
f.config.custom?.gradientMode === GraphGradientMode.Scheme &&
f.config.color?.mode === FieldColorModeId.Thresholds;
return (
fromThresholds ||
f.config.mappings?.some((m) => {
// ValueToText mappings have a different format, where all of them are grouped into an object keyed by value
if (m.type === 'value') {
// === MappingType.ValueToText
return Object.values(m.options).some((result) => result.color != null);
}
return m.options.result.color != null;
})
);
});
if (hasPerBarColor) {
// use opacity from first numeric field
let opacityField = frame0Ref.current!.fields.find((f) => f.type === FieldType.number)!;
fillOpacity = (opacityField.config.custom.fillOpacity ?? 100) / 100;
getColor = (seriesIdx: number, valueIdx: number) => {
let field = frame0Ref.current!.fields[seriesIdx];
return field.display!(field.values[valueIdx]).color!;
};
}
}
const prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const {
barWidth,
barRadius = 0,
showValue,
groupWidth,
stacking,
legend,
tooltip,
text,
xTickLabelRotation,
xTickLabelSpacing,
fullHighlight,
} = options;
return preparePlotConfigBuilder({
frame: alignedFrame,
getTimeRange,
timeZone,
theme,
timeZones: [timeZone],
orientation,
barWidth,
barRadius,
showValue,
groupWidth,
xTickLabelRotation,
xTickLabelMaxLength,
xTickLabelSpacing,
stacking,
legend,
tooltip,
text,
rawValue,
getColor,
fillOpacity,
allFrames: info.viz,
fullHighlight,
hoverMulti: tooltip.mode === TooltipDisplayMode.Multi,
});
};
const legendComp =
legend.showLegend && hasVisibleLegendSeries(builder, info.series!) ? (
<BarChartLegend data={info.series!} colorField={info.color} {...legend} />
) : null;
return (
<GraphNG
theme={theme}
frames={info.viz}
prepConfig={prepConfig}
propsToDiff={propsToDiff}
preparePlotFrame={(f) => f[0]} // already processed in by the panel above!
renderLegend={renderLegend}
legend={options.legend}
timeZone={timeZone}
timeRange={{ from: 1, to: 1 } as unknown as TimeRange} // HACK
structureRev={structureRev}
width={width}
height={height}
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
<VizLayout
width={props.width}
height={props.height}
// legend={<BarChartLegend frame={info.series![0]} colorField={info.color} {...legend} />}
legend={legendComp}
>
{(config) => {
if (options.tooltip.mode !== TooltipDisplayMode.None) {
return (
{(vizWidth, vizHeight) => (
<UPlotChart config={builder!} data={plotData} width={vizWidth} height={vizHeight}>
{props.options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={config}
config={builder}
maxWidth={options.tooltip.maxWidth}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll
}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2) => {
return (
<TimeSeriesTooltip
frames={info.viz}
seriesFrame={info.aligned}
series={vizSeries[0]}
_rest={info._rest}
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}
/>
);
}
return null;
}}
</GraphNG>
)}
</UPlotChart>
)}
</VizLayout>
);
};

View File

@ -28,7 +28,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 1`] = `
"stroke": "rgba(240, 250, 255, 0.09)",
"width": 1,
},
"timeZone": undefined,
"timeZone": "browser",
"values": [Function],
},
{
@ -184,7 +184,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 2`] = `
"stroke": "rgba(240, 250, 255, 0.09)",
"width": 1,
},
"timeZone": undefined,
"timeZone": "browser",
"values": [Function],
},
{
@ -340,7 +340,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 3`] = `
"stroke": "rgba(240, 250, 255, 0.09)",
"width": 1,
},
"timeZone": undefined,
"timeZone": "browser",
"values": [Function],
},
{
@ -496,7 +496,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 1`] = `
"stroke": "rgba(240, 250, 255, 0.09)",
"width": 1,
},
"timeZone": undefined,
"timeZone": "browser",
"values": [Function],
},
{
@ -652,7 +652,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 2`] = `
"stroke": "rgba(240, 250, 255, 0.09)",
"width": 1,
},
"timeZone": undefined,
"timeZone": "browser",
"values": [Function],
},
{
@ -808,7 +808,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 3`] = `
"stroke": "rgba(240, 250, 255, 0.09)",
"width": 1,
},
"timeZone": undefined,
"timeZone": "browser",
"values": [Function],
},
{
@ -964,7 +964,7 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 1`] = `
"stroke": "rgba(240, 250, 255, 0.09)",
"width": 1,
},
"timeZone": undefined,
"timeZone": "browser",
"values": [Function],
},
{
@ -1120,7 +1120,7 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 2`] = `
"stroke": "rgba(240, 250, 255, 0.09)",
"width": 1,
},
"timeZone": undefined,
"timeZone": "browser",
"values": [Function],
},
{

View File

@ -3,12 +3,10 @@ import {
FieldColorModeId,
FieldConfigProperty,
FieldType,
getFieldDisplayName,
identityOverrideProcessor,
PanelPlugin,
VizOrientation,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { GraphTransform, GraphThresholdsStyleMode, StackingMode, VisibilityMode } from '@grafana/schema';
import { graphFieldOptions, commonOptionsBuilder } from '@grafana/ui';
@ -19,7 +17,6 @@ import { TickSpacingEditor } from './TickSpacingEditor';
import { changeToBarChartPanelMigrationHandler } from './migrations';
import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen';
import { BarChartSuggestionsSupplier } from './suggestions';
import { prepareBarChartDisplayValues } from './utils';
export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel)
.setPanelChangeHandler(changeToBarChartPanelMigrationHandler)
@ -109,21 +106,13 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(BarChartPanel)
commonOptionsBuilder.addHideFrom(builder);
},
})
.setPanelOptions((builder, context) => {
const disp = prepareBarChartDisplayValues(context.data, config.theme2, context.options ?? ({} as Options));
let xaxisPlaceholder = 'First string or time field';
const viz = 'viz' in disp ? disp.viz[0] : undefined;
if (viz?.fields?.length) {
const first = viz.fields[0];
xaxisPlaceholder += ` (${getFieldDisplayName(first, viz)})`;
}
.setPanelOptions((builder) => {
builder
.addFieldNamePicker({
path: 'xField',
name: 'X Axis',
settings: {
placeholderText: xaxisPlaceholder,
placeholderText: 'First string or time field',
},
})
.addRadio({

View File

@ -2,12 +2,11 @@ import { assertIsDefined } from 'test/helpers/asserts';
import {
createTheme,
DefaultTimeZone,
FieldConfig,
FieldType,
getDefaultTimeRange,
MutableDataFrame,
VizOrientation,
FieldConfigSource,
} from '@grafana/data';
import {
LegendDisplayMode,
@ -16,10 +15,16 @@ import {
GraphGradientMode,
StackingMode,
SortOrder,
defaultTimeZone,
} from '@grafana/schema';
import { FieldConfig as PanelFieldConfig, Options } from './panelcfg.gen';
import { BarChartOptionsEX, prepareBarChartDisplayValues, preparePlotConfigBuilder } from './utils';
import { FieldConfig as PanelFieldConfig } from './panelcfg.gen';
import { prepSeries, prepConfig, PrepConfigOpts } from './utils';
const fieldConfig: FieldConfigSource = {
defaults: {},
overrides: [],
};
function mockDataFrame() {
const df1 = new MutableDataFrame({
@ -68,13 +73,16 @@ function mockDataFrame() {
state: {},
});
const info = prepareBarChartDisplayValues([df1], createTheme(), {} as Options);
df1.fields.forEach((f) => (f.config.custom = f.config.custom ?? {}));
df2.fields.forEach((f) => (f.config.custom = f.config.custom ?? {}));
if (!('aligned' in info)) {
const info = prepSeries([df1], fieldConfig, StackingMode.None, createTheme());
if (info.series.length === 0) {
throw new Error('Bar chart not prepared correctly');
}
return info.aligned;
return info.series[0];
}
jest.mock('@grafana/data', () => ({
@ -84,95 +92,99 @@ jest.mock('@grafana/data', () => ({
describe('BarChart utils', () => {
describe('preparePlotConfigBuilder', () => {
const frame = mockDataFrame();
const config: BarChartOptionsEX = {
const config: PrepConfigOpts = {
series: [mockDataFrame()],
// color?: Field | null;
timeZone: defaultTimeZone,
theme: createTheme(),
orientation: VizOrientation.Auto,
groupWidth: 20,
barWidth: 2,
showValue: VisibilityMode.Always,
legend: {
displayMode: LegendDisplayMode.List,
showLegend: true,
placement: 'bottom',
calcs: [],
options: {
orientation: VizOrientation.Auto,
groupWidth: 20,
barWidth: 2,
showValue: VisibilityMode.Always,
legend: {
displayMode: LegendDisplayMode.List,
showLegend: true,
placement: 'bottom',
calcs: [],
},
xTickLabelRotation: 0,
xTickLabelMaxLength: 20,
stacking: StackingMode.None,
tooltip: {
mode: TooltipDisplayMode.None,
sort: SortOrder.None,
},
text: {
valueSize: 10,
},
fullHighlight: false,
},
xTickLabelRotation: 0,
xTickLabelMaxLength: 20,
stacking: StackingMode.None,
tooltip: {
mode: TooltipDisplayMode.None,
sort: SortOrder.None,
},
text: {
valueSize: 10,
},
fullHighlight: false,
rawValue: (seriesIdx: number, valueIdx: number) => frame.fields[seriesIdx].values[valueIdx],
};
it.each([VizOrientation.Auto, VizOrientation.Horizontal, VizOrientation.Vertical])('orientation', (v) => {
const result = preparePlotConfigBuilder({
const result = prepConfig({
...config,
options: {
...config.options,
orientation: v,
},
series: [mockDataFrame()],
orientation: v,
frame: frame!,
theme: createTheme(),
timeZones: [DefaultTimeZone],
getTimeRange: getDefaultTimeRange,
allFrames: [frame],
}).getConfig();
}).builder.getConfig();
expect(result).toMatchSnapshot();
});
it.each([VisibilityMode.Always, VisibilityMode.Auto])('value visibility', (v) => {
expect(
preparePlotConfigBuilder({
prepConfig({
...config,
showValue: v,
frame: frame!,
theme: createTheme(),
timeZones: [DefaultTimeZone],
getTimeRange: getDefaultTimeRange,
allFrames: [frame],
}).getConfig()
options: {
...config.options,
showValue: v,
},
series: [mockDataFrame()],
}).builder.getConfig()
).toMatchSnapshot();
});
it.each([StackingMode.None, StackingMode.Percent, StackingMode.Normal])('stacking', (v) => {
expect(
preparePlotConfigBuilder({
prepConfig({
...config,
stacking: v,
frame: frame!,
theme: createTheme(),
timeZones: [DefaultTimeZone],
getTimeRange: getDefaultTimeRange,
allFrames: [frame],
}).getConfig()
options: {
...config.options,
stacking: v,
},
series: [mockDataFrame()],
}).builder.getConfig()
).toMatchSnapshot();
});
});
describe('prepareGraphableFrames', () => {
it('will warn when there is no frames in the response', () => {
const result = prepareBarChartDisplayValues([], createTheme(), { stacking: StackingMode.None } as Options);
const warning = assertIsDefined('warn' in result ? result : null);
const info = prepSeries([], fieldConfig, StackingMode.None, createTheme());
const warning = assertIsDefined('warn' in info ? info : null);
expect(warning.warn).toEqual('No data in response');
});
it('will warn when there is no data in the response', () => {
const result = prepareBarChartDisplayValues(
const info = prepSeries(
[
{
length: 0,
fields: [],
},
],
createTheme(),
{ stacking: StackingMode.None } as Options
fieldConfig,
StackingMode.None,
createTheme()
);
const warning = assertIsDefined('warn' in result ? result : null);
const warning = assertIsDefined('warn' in info ? info : null);
expect(warning.warn).toEqual('No data in response');
});
@ -184,10 +196,11 @@ describe('BarChart utils', () => {
{ name: 'value', values: [1, 2, 3, 4, 5] },
],
});
const result = prepareBarChartDisplayValues([df], createTheme(), { stacking: StackingMode.None } as Options);
const warning = assertIsDefined('warn' in result ? result : null);
df.fields.forEach((f) => (f.config.custom = f.config.custom ?? {}));
const info = prepSeries([df], fieldConfig, StackingMode.None, createTheme());
const warning = assertIsDefined('warn' in info ? info : null);
expect(warning.warn).toEqual('Bar charts requires a string or time field');
expect(warning).not.toHaveProperty('viz');
});
it('will warn when there are no numeric fields in the response', () => {
@ -197,10 +210,11 @@ describe('BarChart utils', () => {
{ name: 'value', type: FieldType.boolean, values: [true, true, true, true, true] },
],
});
const result = prepareBarChartDisplayValues([df], createTheme(), { stacking: StackingMode.None } as Options);
const warning = assertIsDefined('warn' in result ? result : null);
df.fields.forEach((f) => (f.config.custom = f.config.custom ?? {}));
const info = prepSeries([df], fieldConfig, StackingMode.None, createTheme());
const warning = assertIsDefined('warn' in info ? info : null);
expect(warning.warn).toEqual('No numeric fields found');
expect(warning).not.toHaveProperty('viz');
});
it('will convert NaN and Infinty to nulls', () => {
@ -210,10 +224,11 @@ describe('BarChart utils', () => {
{ name: 'value', values: [-10, NaN, 10, -Infinity, +Infinity] },
],
});
const result = prepareBarChartDisplayValues([df], createTheme(), { stacking: StackingMode.None } as Options);
const displayValues = assertIsDefined('viz' in result ? result : null);
df.fields.forEach((f) => (f.config.custom = f.config.custom ?? {}));
const field = displayValues.viz[0].fields[1];
const info = prepSeries([df], fieldConfig, StackingMode.None, createTheme());
const field = info.series[0].fields[1];
expect(field.values).toMatchInlineSnapshot(`
[
-10,
@ -223,23 +238,10 @@ describe('BarChart utils', () => {
null,
]
`);
const displayLegendValuesAsc = assertIsDefined('legend' in result ? result : null).legend;
const legendField = displayLegendValuesAsc.fields[1];
expect(legendField.values).toMatchInlineSnapshot(`
[
-10,
null,
10,
null,
null,
]
`);
});
it('should remove unit from legend values when stacking is percent', () => {
const frame = new MutableDataFrame({
it('should not apply % unit to series when stacking is percent', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'string', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'a', values: [-10, 20, 10], state: { calcs: { min: -10 } } },
@ -247,15 +249,13 @@ describe('BarChart utils', () => {
{ name: 'c', values: [10, 10, 10], state: { calcs: { min: 10 } } },
],
});
df.fields.forEach((f) => (f.config.custom = f.config.custom ?? {}));
const resultAsc = prepareBarChartDisplayValues([frame], createTheme(), {
stacking: StackingMode.Percent,
} as Options);
const displayLegendValuesAsc = assertIsDefined('legend' in resultAsc ? resultAsc : null).legend;
const info = prepSeries([df], fieldConfig, StackingMode.Percent, createTheme());
expect(displayLegendValuesAsc.fields[0].config.unit).toBeUndefined();
expect(displayLegendValuesAsc.fields[1].config.unit).toBeUndefined();
expect(displayLegendValuesAsc.fields[2].config.unit).toBeUndefined();
expect(info.series[0].fields[0].config.unit).toBeUndefined();
expect(info.series[0].fields[1].config.unit).toBeUndefined();
expect(info.series[0].fields[2].config.unit).toBeUndefined();
});
});
});

View File

@ -1,96 +1,200 @@
import { cloneDeep } from 'lodash';
import uPlot, { Padding } from 'uplot';
import {
DataFrame,
Field,
FieldConfigSource,
FieldType,
GrafanaTheme2,
cacheFieldDisplayNames,
formattedValueToString,
getDisplayProcessor,
getFieldColorModeForField,
cacheFieldDisplayNames,
getFieldSeriesColor,
GrafanaTheme2,
outerJoinDataFrames,
TimeZone,
VizOrientation,
getFieldDisplayName,
} from '@grafana/data';
import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames';
import { decoupleHideFromState } from '@grafana/data/src/field/fieldState';
import {
AxisColorMode,
AxisPlacement,
GraphTransform,
FieldColorModeId,
GraphGradientMode,
GraphThresholdsStyleMode,
ScaleDirection,
GraphTransform,
ScaleDistribution,
TimeZone,
TooltipDisplayMode,
VizOrientation,
} from '@grafana/schema';
import {
FIXED_UNIT,
ScaleDirection,
ScaleOrientation,
StackingMode,
VizLegendOptions,
} from '@grafana/schema';
import { FIXED_UNIT, measureText, UPlotConfigBuilder, UPlotConfigPrepFn, UPLOT_AXIS_FONT_SIZE } from '@grafana/ui';
import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
UPlotConfigBuilder,
measureText,
} from '@grafana/ui';
import { AxisProps, UPLOT_AXIS_FONT_SIZE } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
import { getStackingGroups } from '@grafana/ui/src/components/uPlot/utils';
import { findField } from 'app/features/dimensions';
import { setClassicPaletteIdxs } from '../timeseries/utils';
import { BarsOptions, getConfig } from './bars';
import { FieldConfig, Options, defaultFieldConfig } from './panelcfg.gen';
import { BarChartDisplayValues, BarChartDisplayWarning } from './types';
// import { isLegendOrdered } from './utils';
interface BarSeries {
series: DataFrame[];
_rest: Field[];
color?: Field | null;
warn?: string | null;
}
export function prepSeries(
frames: DataFrame[],
fieldConfig: FieldConfigSource<any>,
stacking: StackingMode,
theme: GrafanaTheme2,
xFieldName?: string,
colorFieldName?: string
): BarSeries {
if (frames.length === 0 || frames.every((fr) => fr.length === 0)) {
return { series: [], _rest: [], warn: 'No data in response' };
}
cacheFieldDisplayNames(frames);
decoupleHideFromState(frames, fieldConfig);
let frame: DataFrame | undefined = { ...frames[0] };
// auto-sort and/or join on first time field (if any)
// TODO: should this always join on the xField (if supplied?)
const timeFieldIdx = frame.fields.findIndex((f) => f.type === FieldType.time);
if (timeFieldIdx >= 0 && frames.length > 1) {
frame = outerJoinDataFrames({ frames, keepDisplayNames: true }) ?? frame;
}
const xField =
// TODO: use matcher
frame.fields.find((field) => field.state?.displayName === xFieldName || field.name === xFieldName) ??
frame.fields.find((field) => field.type === FieldType.string) ??
frame.fields[timeFieldIdx];
if (xField != null) {
const fields: Field[] = [xField];
const _rest: Field[] = [];
const colorField =
colorFieldName == null
? undefined
: frame.fields.find(
// TODO: use matcher
(field) => field.state?.displayName === colorFieldName || field.name === colorFieldName
);
frame.fields.forEach((field) => {
if (field !== xField) {
if (field.type === FieldType.number && !field.config.custom?.hideFrom?.viz) {
const field2 = {
...field,
values: field.values.map((v) => (Number.isFinite(v) ? v : null)),
// TODO: stacking should be moved from panel opts to fieldConfig (like TimeSeries) so we dont have to do this
config: {
...field.config,
custom: {
...field.config.custom,
stacking: {
group: '_',
mode: stacking,
},
},
},
};
fields.push(field2);
} else {
_rest.push(field);
}
}
});
let warn: string | null = null;
if (fields.length === 1) {
warn = 'No numeric fields found';
}
frame.fields = fields;
const series = [frame];
setClassicPaletteIdxs(series, theme, 0);
function getBarCharScaleOrientation(orientation: VizOrientation) {
if (orientation === VizOrientation.Vertical) {
return {
xOri: ScaleOrientation.Horizontal,
xDir: ScaleDirection.Right,
yOri: ScaleOrientation.Vertical,
yDir: ScaleDirection.Up,
series,
_rest,
color: colorField,
warn,
};
}
return {
xOri: ScaleOrientation.Vertical,
xDir: ScaleDirection.Down,
yOri: ScaleOrientation.Horizontal,
yDir: ScaleDirection.Right,
series: [],
_rest: [],
color: null,
warn: 'Bar charts requires a string or time field',
};
}
export interface BarChartOptionsEX extends Options {
rawValue: (seriesIdx: number, valueIdx: number) => number | null;
getColor?: (seriesIdx: number, valueIdx: number, value: unknown) => string | null;
timeZone?: TimeZone;
fillOpacity?: number;
hoverMulti?: boolean;
export interface PrepConfigOpts {
series: DataFrame[];
color?: Field | null;
orientation: VizOrientation;
options: Options;
timeZone: TimeZone;
theme: GrafanaTheme2;
}
export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
frame,
theme,
orientation,
showValue,
groupWidth,
barWidth,
barRadius = 0,
stacking,
text,
rawValue,
getColor,
fillOpacity,
allFrames,
xTickLabelRotation,
xTickLabelMaxLength,
xTickLabelSpacing = 0,
legend,
timeZone,
fullHighlight,
hoverMulti,
}) => {
export const prepConfig = ({ series, color, orientation, options, timeZone, theme }: PrepConfigOpts) => {
let {
showValue,
groupWidth,
barWidth,
barRadius = 0,
stacking,
text,
tooltip,
xTickLabelRotation,
xTickLabelMaxLength,
xTickLabelSpacing = 0,
legend,
fullHighlight,
} = options;
// this and color is kept up to date by returned prepData()
let frame = series[0];
const builder = new UPlotConfigBuilder();
const formatters = frame.fields.map((f, i) => {
if (stacking === StackingMode.Percent) {
return getDisplayProcessor({
field: {
...f,
config: {
...f.config,
unit: 'percentunit',
},
},
theme,
});
}
return f.display!;
});
const formatValue = (seriesIdx: number, value: unknown) => {
return formattedValueToString(frame.fields[seriesIdx].display!(value));
return formattedValueToString(formatters[seriesIdx](value));
};
const formatShortValue = (seriesIdx: number, value: unknown) => {
@ -98,7 +202,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
};
// bar orientation -> x scale orientation & direction
const vizOrientation = getBarCharScaleOrientation(orientation);
const vizOrientation = getScaleOrientation(orientation);
// Use bar width when only one field
if (frame.fields.length === 2) {
@ -106,6 +210,52 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
barWidth = 1;
}
const rawValue = (seriesIdx: number, valueIdx: number) => {
return frame.fields[seriesIdx].values[valueIdx];
};
// Color by value
let getColor: ((seriesIdx: number, valueIdx: number) => string) | undefined = undefined;
let fillOpacity = 1;
if (color != null) {
const disp = color.display!;
fillOpacity = (color.config.custom.fillOpacity ?? 100) / 100;
// gradientMode? ignore?
getColor = (seriesIdx: number, valueIdx: number) => disp(color!.values[valueIdx]).color!;
} else {
const hasPerBarColor = frame.fields.some((f) => {
const fromThresholds =
f.config.custom?.gradientMode === GraphGradientMode.Scheme &&
f.config.color?.mode === FieldColorModeId.Thresholds;
return (
fromThresholds ||
f.config.mappings?.some((m) => {
// ValueToText mappings have a different format, where all of them are grouped into an object keyed by value
if (m.type === 'value') {
// === MappingType.ValueToText
return Object.values(m.options).some((result) => result.color != null);
}
return m.options.result.color != null;
})
);
});
if (hasPerBarColor) {
// use opacity from first numeric field
let opacityField = frame.fields.find((f) => f.type === FieldType.number)!;
fillOpacity = (opacityField.config.custom.fillOpacity ?? 100) / 100;
getColor = (seriesIdx: number, valueIdx: number) => {
let field = frame.fields[seriesIdx];
return field.display!(field.values[valueIdx]).color!;
};
}
}
const opts: BarsOptions = {
xOri: vizOrientation.xOri,
xDir: vizOrientation.xDir,
@ -126,7 +276,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
xTimeAuto: frame.fields[0]?.type === FieldType.time && !frame.fields[0].config.unit?.startsWith('time:'),
negY: frame.fields.map((f) => f.config.custom?.transform === GraphTransform.NegativeY),
fullHighlight,
hoverMulti,
hoverMulti: tooltip.mode === TooltipDisplayMode.Multi,
};
const config = getConfig(opts, theme);
@ -182,14 +332,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
show: xFieldAxisShow,
});
let seriesIndex = 0;
const legendOrdered = isLegendOrdered(legend);
// let seriesIndex = 0;
// const legendOrdered = isLegendOrdered(legend);
// iterate the y values
for (let i = 1; i < frame.fields.length; i++) {
const field = frame.fields[i];
seriesIndex++;
// seriesIndex++;
const customConfig: FieldConfig = { ...defaultFieldConfig, ...field.config.custom };
@ -246,14 +396,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
// PlotLegend currently gets unfiltered DataFrame[], so index must be into that field array, not the prepped frame's which we're iterating here
dataFrameFieldIndex: {
fieldIndex: legendOrdered
? i
: allFrames[0].fields.findIndex(
(f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIndex - 1
),
frameIndex: 0,
},
// dataFrameFieldIndex: {
// fieldIndex: legendOrdered
// ? i
// : allFrames[0].fields.findIndex(
// (f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIndex - 1
// ),
// frameIndex: 0,
// },
});
// The builder will manage unique scaleKeys and combine where appropriate
@ -314,7 +464,16 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
builder.setStackingGroups(stackingGroups);
return builder;
return {
builder,
prepData: (_series: DataFrame[], _color?: Field | null) => {
series = _series;
frame = series[0];
color = _color;
return builder.prepData!(series);
},
};
};
function shortenValue(value: string, length: number) {
@ -373,167 +532,20 @@ function getRotationPadding(
];
}
/** @internal */
export function prepareBarChartDisplayValues(
series: DataFrame[],
theme: GrafanaTheme2,
options: Options
): BarChartDisplayValues | BarChartDisplayWarning {
if (!series.length || series.every((fr) => fr.length === 0)) {
return { warn: 'No data in response' };
}
cacheFieldDisplayNames(series);
// Bar chart requires a single frame
const frame =
series.length === 1
? maybeSortFrame(
series[0],
series[0].fields.findIndex((f) => f.type === FieldType.time)
)
: outerJoinDataFrames({ frames: series, keepDisplayNames: true });
if (!frame) {
return { warn: 'Unable to join data' };
}
// Color by a field different than the input
let colorByField: Field | undefined = undefined;
if (options.colorByField) {
colorByField = findField(frame, options.colorByField);
if (!colorByField) {
return { warn: 'Color field not found' };
}
}
let xField: Field | undefined = undefined;
if (options.xField) {
xField = findField(frame, options.xField);
if (!xField) {
return { warn: 'Configured x field not found' };
}
}
let stringField: Field | undefined = undefined;
let timeField: Field | undefined = undefined;
let fields: Field[] = [];
for (const field of frame.fields) {
if (field === xField) {
continue;
}
switch (field.type) {
case FieldType.string:
if (!stringField) {
stringField = field;
}
break;
case FieldType.time:
if (!timeField) {
timeField = field;
}
break;
case FieldType.number: {
const copy = {
...field,
state: {
...field.state,
seriesIndex: fields.length, // off by one?
},
config: {
...field.config,
custom: {
...field.config.custom,
stacking: {
group: '_',
mode: options.stacking,
},
},
},
values: field.values.map((v) => {
if (!(Number.isFinite(v) || v == null)) {
return null;
}
return v;
}),
};
if (options.stacking === StackingMode.Percent) {
copy.config.unit = 'percentunit';
copy.display = getDisplayProcessor({ field: copy, theme });
}
fields.push(copy);
}
}
}
let firstField = xField;
if (!firstField) {
firstField = stringField || timeField;
}
if (!firstField) {
function getScaleOrientation(orientation: VizOrientation) {
if (orientation === VizOrientation.Vertical) {
return {
warn: 'Bar charts requires a string or time field',
xOri: ScaleOrientation.Horizontal,
xDir: ScaleDirection.Right,
yOri: ScaleOrientation.Vertical,
yDir: ScaleDirection.Up,
};
}
// if both string and time fields exist, remove unused leftover time field
if (frame.fields[0].type === FieldType.time && frame.fields[0] !== firstField) {
frame.fields.shift();
}
setClassicPaletteIdxs([frame], theme, 0);
if (!fields.length) {
return {
warn: 'No numeric fields found',
};
}
// Show the first number value
if (colorByField && fields.length > 1) {
const firstNumber = fields.find((f) => f !== colorByField);
if (firstNumber) {
fields = [firstNumber];
}
}
// If stacking is percent, we need to correct the legend fields unit and display
let legendFields: Field[] = cloneDeep(fields);
if (options.stacking === StackingMode.Percent) {
legendFields.map((field) => {
const alignedFrameField = frame.fields.find(
(f) => getFieldDisplayName(f, frame) === getFieldDisplayName(f, frame)
);
field.config.unit = alignedFrameField?.config?.unit ?? undefined;
field.display = getDisplayProcessor({ field: field, theme });
});
}
// String field is first, make sure fields / legend fields indexes match
fields.unshift(firstField);
legendFields.unshift(firstField);
return {
aligned: frame,
colorByField,
viz: [
{
fields: fields, // ideally: fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.viz)),
length: firstField.values.length,
},
],
legend: {
fields: legendFields,
length: firstField.values.length,
},
xOri: ScaleOrientation.Vertical,
xDir: ScaleDirection.Down,
yOri: ScaleOrientation.Horizontal,
yDir: ScaleDirection.Right,
};
}
export const isLegendOrdered = (options: VizLegendOptions) => Boolean(options?.sortBy && options.sortDesc !== null);

View File

@ -299,8 +299,7 @@ export const CandlestickPanel = ({
return (
<TimeSeriesTooltip
frames={[info.frame]}
seriesFrame={alignedFrame}
series={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}

View File

@ -115,8 +115,7 @@ export const StateTimelinePanel = ({
return (
<StateTimelineTooltip2
frames={frames ?? []}
seriesFrame={alignedFrame}
series={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}

View File

@ -1,6 +1,6 @@
import React, { ReactNode } from 'react';
import { FieldType, getFieldDisplayName, TimeRange } from '@grafana/data';
import { FieldType, TimeRange } from '@grafana/data';
import { SortOrder } from '@grafana/schema/dist/esm/common/common.gen';
import { TooltipDisplayMode, useStyles2 } from '@grafana/ui';
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
@ -19,8 +19,7 @@ interface StateTimelineTooltip2Props extends TimeSeriesTooltipProps {
}
export const StateTimelineTooltip2 = ({
frames,
seriesFrame,
series,
dataIdxs,
seriesIdx,
mode = TooltipDisplayMode.Single,
@ -34,7 +33,7 @@ export const StateTimelineTooltip2 = ({
}: StateTimelineTooltip2Props) => {
const styles = useStyles2(getStyles);
const xField = seriesFrame.fields[0];
const xField = series.fields[0];
const dataIdx = seriesIdx != null ? dataIdxs[seriesIdx] : dataIdxs.find((idx) => idx != null);
@ -42,11 +41,11 @@ export const StateTimelineTooltip2 = ({
mode = isPinned ? TooltipDisplayMode.Single : mode;
const contentItems = getContentItems(seriesFrame.fields, xField, dataIdxs, seriesIdx, mode, sortOrder);
const contentItems = getContentItems(series.fields, xField, dataIdxs, seriesIdx, mode, sortOrder);
// append duration in single mode
if (withDuration && mode === TooltipDisplayMode.Single) {
const field = seriesFrame.fields[seriesIdx!];
const field = series.fields[seriesIdx!];
const nextStateIdx = findNextStateIndex(field, dataIdx!);
let nextStateTs;
if (nextStateIdx) {
@ -69,7 +68,7 @@ export const StateTimelineTooltip2 = ({
let footer: ReactNode;
if (isPinned && seriesIdx != null) {
const field = seriesFrame.fields[seriesIdx];
const field = series.fields[seriesIdx];
const dataIdx = dataIdxs[seriesIdx]!;
const links = getDataLinks(field, dataIdx);
@ -77,7 +76,7 @@ export const StateTimelineTooltip2 = ({
}
const headerItem: VizTooltipItem = {
label: xField.type === FieldType.time ? '' : getFieldDisplayName(xField, seriesFrame, frames),
label: xField.type === FieldType.time ? '' : xField.state?.displayName ?? xField.name,
value: xVal,
};

View File

@ -120,8 +120,7 @@ export const StatusHistoryPanel = ({
return (
<StateTimelineTooltip2
frames={frames ?? []}
seriesFrame={alignedFrame}
series={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}

View File

@ -123,8 +123,7 @@ export const TimeSeriesPanel = ({
return (
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
<TimeSeriesTooltip
frames={frames}
seriesFrame={alignedFrame}
series={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import React, { ReactNode } from 'react';
import { DataFrame, FieldType, getFieldDisplayName } from '@grafana/data';
import { DataFrame, Field, FieldType } from '@grafana/data';
import { SortOrder, TooltipDisplayMode } from '@grafana/schema/dist/esm/common/common.gen';
import { useStyles2 } from '@grafana/ui';
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
@ -11,14 +11,18 @@ import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types';
import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils';
import { getDataLinks } from '../status-history/utils';
import { fmt } from '../xychart/utils';
// exemplar / annotation / time region hovering?
// add annotation UI / alert dismiss UI?
export interface TimeSeriesTooltipProps {
frames?: DataFrame[];
// aligned series frame
seriesFrame: DataFrame;
series: DataFrame;
// aligned fields that are not series
_rest?: Field[];
// hovered points
dataIdxs: Array<number | null>;
// closest/hovered series
@ -34,8 +38,8 @@ export interface TimeSeriesTooltipProps {
}
export const TimeSeriesTooltip = ({
frames,
seriesFrame,
series,
_rest,
dataIdxs,
seriesIdx,
mode = TooltipDisplayMode.Single,
@ -47,12 +51,12 @@ export const TimeSeriesTooltip = ({
}: TimeSeriesTooltipProps) => {
const styles = useStyles2(getStyles);
const xField = seriesFrame.fields[0];
const xField = series.fields[0];
const xVal = xField.display!(xField.values[dataIdxs[0]!]).text;
const contentItems = getContentItems(
seriesFrame.fields,
series.fields,
xField,
dataIdxs,
seriesIdx,
@ -61,24 +65,35 @@ export const TimeSeriesTooltip = ({
(field) => field.type === FieldType.number || field.type === FieldType.enum
);
_rest?.forEach((field) => {
if (!field.config.custom?.hideFrom?.tooltip) {
contentItems.push({
label: field.state?.displayName ?? field.name,
value: fmt(field, field.values[dataIdxs[0]!]),
});
}
});
let footer: ReactNode;
if (isPinned && seriesIdx != null) {
const field = seriesFrame.fields[seriesIdx];
const field = series.fields[seriesIdx];
const dataIdx = dataIdxs[seriesIdx]!;
const links = getDataLinks(field, dataIdx);
footer = <VizTooltipFooter dataLinks={links} annotate={annotate} />;
}
const headerItem: VizTooltipItem = {
label: xField.type === FieldType.time ? '' : getFieldDisplayName(xField, seriesFrame, frames),
value: xVal,
};
const headerItem: VizTooltipItem | null = xField.config.custom?.hideFrom?.tooltip
? null
: {
label: xField.type === FieldType.time ? '' : xField.state?.displayName ?? xField.name,
value: xVal,
};
return (
<div className={styles.wrapper}>
<VizTooltipHeader item={headerItem} isPinned={isPinned} />
{headerItem != null && <VizTooltipHeader item={headerItem} isPinned={isPinned} />}
<VizTooltipContent items={contentItems} isPinned={isPinned} scrollable={scrollable} maxHeight={maxHeight} />
{footer}
</div>

View File

@ -85,7 +85,7 @@ export const AnnotationMarker2 = ({
>
{contents &&
createPortal(
<div ref={refs.setFloating} className={styles.annoBox} style={floatingStyles}>
<div ref={refs.setFloating} className={styles.annoBox} style={floatingStyles} data-testid="annotation-marker">
{contents}
</div>,
portalRoot

View File

@ -123,8 +123,7 @@ export const TrendPanel = ({
render={(u, dataIdxs, seriesIdx, isPinned = false) => {
return (
<TimeSeriesTooltip
frames={info.frames!}
seriesFrame={alignedDataFrame}
series={alignedDataFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={options.tooltip.mode}