VizTooltips: Fix sorting (#82278)

This commit is contained in:
Leon Sorokin 2024-02-15 12:54:43 -06:00 committed by GitHub
parent 80f324fadb
commit 4b67ac117f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 393 additions and 531 deletions

View File

@ -19,11 +19,14 @@ export enum ColorPlacement {
export interface LabelValue {
label: string;
value: string | number | null;
value: string;
color?: string;
colorIndicator?: ColorIndicator;
colorPlacement?: ColorPlacement;
isActive?: boolean;
// internal/tmp for sorting
numeric?: number;
}
export const DEFAULT_COLOR_INDICATOR = ColorIndicator.series;

View File

@ -1,4 +1,7 @@
import { calculateTooltipPosition } from './utils';
import { DataFrame, FieldType } from '@grafana/data';
import { SortOrder, TooltipDisplayMode } from '@grafana/schema';
import { calculateTooltipPosition, getContentItems } from './utils';
describe('utils', () => {
describe('calculateTooltipPosition', () => {
@ -162,4 +165,171 @@ describe('utils', () => {
});
});
});
describe('it tests getContentItems with numeric values', () => {
const timeValues = [1707833954056, 1707838274056, 1707842594056];
const seriesAValues = [1, 20, 70];
const seriesBValues = [-100, -26, null];
const frame = {
name: 'a',
length: timeValues.length,
fields: [
{
name: 'time',
type: FieldType.time,
values: timeValues[0],
config: {},
display: (value: string) => ({
text: value,
color: undefined,
numeric: NaN,
}),
},
{
name: 'A-series',
type: FieldType.number,
values: seriesAValues,
config: {},
display: (value: string) => ({
text: value,
color: undefined,
numeric: Number(value),
}),
},
{
name: 'B-series',
type: FieldType.number,
values: seriesBValues,
config: {},
display: (value: string) => ({
text: value,
color: undefined,
numeric: Number(value),
}),
},
],
} as unknown as DataFrame;
const fields = frame.fields;
const xField = frame.fields[0];
const dataIdxs = [1, 1, 1];
it('displays one series in single mode', () => {
const rows = getContentItems(fields, xField, dataIdxs, 2, TooltipDisplayMode.Single, SortOrder.None);
expect(rows.length).toBe(1);
expect(rows[0].value).toBe('-26');
});
it('displays the right content in multi mode', () => {
const rows = getContentItems(fields, xField, dataIdxs, null, TooltipDisplayMode.Multi, SortOrder.None);
expect(rows.length).toBe(2);
expect(rows[0].value).toBe('20');
expect(rows[1].value).toBe('-26');
});
it('displays the values sorted ASC', () => {
const rows = getContentItems(fields, xField, dataIdxs, null, TooltipDisplayMode.Multi, SortOrder.Ascending);
expect(rows.length).toBe(2);
expect(rows[0].value).toBe('-26');
expect(rows[1].value).toBe('20');
});
it('displays the values sorted DESC', () => {
const rows = getContentItems(fields, xField, dataIdxs, null, TooltipDisplayMode.Multi, SortOrder.Descending);
expect(rows.length).toBe(2);
expect(rows[0].value).toBe('20');
expect(rows[1].value).toBe('-26');
});
it('displays the correct value when NULL values', () => {
const rows = getContentItems(fields, xField, [2, 2, null], null, TooltipDisplayMode.Multi, SortOrder.Descending);
expect(rows.length).toBe(1);
expect(rows[0].value).toBe('70');
});
});
describe('it tests getContentItems with string values', () => {
const timeValues = [1707833954056, 1707838274056, 1707842594056];
const seriesAValues = ['LOW', 'HIGH', 'NORMAL'];
const seriesBValues = ['NORMAL', 'LOW', 'LOW'];
const frame = {
name: 'a',
length: timeValues.length,
fields: [
{
name: 'time',
type: FieldType.time,
values: timeValues[0],
config: {},
display: (value: string) => ({
text: value,
color: undefined,
numeric: NaN,
}),
},
{
name: 'A-series',
type: FieldType.string,
values: seriesAValues,
config: {},
display: (value: string) => ({
text: value,
color: undefined,
numeric: NaN,
}),
},
{
name: 'B-series',
type: FieldType.string,
values: seriesBValues,
config: {},
display: (value: string) => ({
text: value,
color: undefined,
numeric: NaN,
}),
},
],
} as unknown as DataFrame;
const fields = frame.fields;
const xField = frame.fields[0];
const dataIdxs = [null, 0, 0];
it('displays one series in single mode', () => {
const rows = getContentItems(fields, xField, [null, null, 0], 2, TooltipDisplayMode.Single, SortOrder.None);
expect(rows.length).toBe(1);
expect(rows[0].value).toBe('NORMAL');
});
it('displays the right content in multi mode', () => {
const rows = getContentItems(fields, xField, dataIdxs, 2, TooltipDisplayMode.Multi, SortOrder.None);
expect(rows.length).toBe(2);
expect(rows[0].value).toBe('LOW');
expect(rows[1].value).toBe('NORMAL');
});
it('displays the values sorted ASC', () => {
const rows = getContentItems(fields, xField, dataIdxs, 2, TooltipDisplayMode.Multi, SortOrder.Ascending);
expect(rows.length).toBe(2);
expect(rows[0].value).toBe('LOW');
expect(rows[1].value).toBe('NORMAL');
});
it('displays the values sorted DESC', () => {
const rows = getContentItems(
frame.fields,
frame.fields[0],
dataIdxs,
2,
TooltipDisplayMode.Multi,
SortOrder.Descending
);
expect(rows.length).toBe(2);
expect(rows[0].value).toBe('NORMAL');
expect(rows[1].value).toBe('LOW');
});
});
});

View File

@ -1,5 +1,8 @@
import { FALLBACK_COLOR, Field, FieldType, formattedValueToString } from '@grafana/data';
import { SortOrder, TooltipDisplayMode } from '@grafana/schema';
import { ColorIndicatorStyles } from './VizTooltipColorIndicator';
import { ColorIndicator } from './types';
import { ColorIndicator, ColorPlacement, LabelValue } from './types';
export const calculateTooltipPosition = (
xPos = 0,
@ -66,3 +69,80 @@ export const getColorIndicatorClass = (colorIndicator: string, styles: ColorIndi
return styles.value;
}
};
const numberCmp = (a: LabelValue, b: LabelValue) => a.numeric! - b.numeric!;
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
const stringCmp = (a: LabelValue, b: LabelValue) => collator.compare(`${a.value}`, `${b.value}`);
export const getContentItems = (
fields: Field[],
xField: Field,
dataIdxs: Array<number | null>,
seriesIdx: number | null | undefined,
mode: TooltipDisplayMode,
sortOrder: SortOrder,
fieldFilter = (field: Field) => true
): LabelValue[] => {
let rows: LabelValue[] = [];
let allNumeric = false;
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
if (
field === xField ||
field.type === FieldType.time ||
!fieldFilter(field) ||
field.config.custom?.hideFrom?.tooltip ||
field.config.custom?.hideFrom?.viz
) {
continue;
}
// in single mode, skip all but closest field
if (mode === TooltipDisplayMode.Single && seriesIdx !== i) {
continue;
}
let dataIdx = dataIdxs[i];
// omit non-hovered
if (dataIdx == null) {
continue;
}
if (!(field.type === FieldType.number || field.type === FieldType.boolean || field.type === FieldType.enum)) {
allNumeric = false;
}
const v = fields[i].values[dataIdx];
// no value -> zero?
const display = field.display!(v); // super expensive :(
// sort NaN and non-numeric to bottom (regardless of sort order)
const numeric = !Number.isNaN(display.numeric)
? display.numeric
: sortOrder === SortOrder.Descending
? Number.MIN_SAFE_INTEGER
: Number.MAX_SAFE_INTEGER;
rows.push({
label: field.state?.displayName ?? field.name,
value: formattedValueToString(display),
color: display.color ?? FALLBACK_COLOR,
colorIndicator: ColorIndicator.series,
colorPlacement: ColorPlacement.first,
isActive: mode === TooltipDisplayMode.Multi && seriesIdx === i,
numeric,
});
}
if (sortOrder !== SortOrder.None && rows.length > 1) {
const cmp = allNumeric ? numberCmp : stringCmp;
const mult = sortOrder === SortOrder.Descending ? -1 : 1;
rows.sort((a, b) => mult * cmp(a, b));
}
return rows;
};

View File

@ -1,7 +1,7 @@
import React from 'react';
import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data';
import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema';
import { VisibilityMode, TimelineValueAlignment, TooltipDisplayMode } from '@grafana/schema';
import { PanelContext, PanelContextRoot, UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui';
import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG';
@ -58,6 +58,8 @@ export class TimelineChart extends React.Component<TimelineProps> {
// When there is only one row, use the full space
rowHeight: alignedFrame.fields.length > 2 ? this.props.rowHeight : 1,
getValueColor: this.getValueColor,
// @ts-ignore
hoverMulti: this.props.tooltip.mode === TooltipDisplayMode.Multi,
});
};

View File

@ -55,6 +55,7 @@ export interface TimelineCoreOptions {
getFieldConfig: (seriesIdx: number) => StateTimeLineFieldConfig | StatusHistoryFieldConfig;
onHover: (seriesIdx: number, valueIdx: number, rect: Rect) => void;
onLeave: () => void;
hoverMulti: boolean;
}
/**
@ -79,6 +80,7 @@ export function getConfig(opts: TimelineCoreOptions) {
getFieldConfig,
onHover,
onLeave,
hoverMulti,
} = opts;
let qt: Quadtree;
@ -405,8 +407,6 @@ export function getConfig(opts: TimelineCoreOptions) {
}
}
const hoverMulti = mode === TimelineMode.Changes;
const cursor: uPlot.Cursor = {
x: mode === TimelineMode.Changes,
y: false,

View File

@ -63,6 +63,7 @@ interface UPlotConfigOptions {
getValueColor: (frameIdx: number, fieldIdx: number, value: unknown) => string;
// Identifies the shared key for uPlot cursor sync
eventsScope?: string;
hoverMulti: boolean;
}
/**
@ -105,6 +106,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
mergeValues,
getValueColor,
eventsScope = '__global_',
hoverMulti,
}) => {
const builder = new UPlotConfigBuilder(timeZones[0]);
@ -165,6 +167,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
hoveredDataIdx = null;
shouldChangeHover = true;
},
hoverMulti,
};
let shouldChangeHover = false;

View File

@ -297,6 +297,7 @@ export const CandlestickPanel = ({
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
annotate={enableAnnotationCreation ? annotate : undefined}
scrollable={isTooltipScrollable(options.tooltip)}

View File

@ -1,4 +1,3 @@
import { css } from '@emotion/css';
import React, { ReactElement, useEffect, useRef, useState } from 'react';
import uPlot from 'uplot';
@ -9,7 +8,6 @@ import {
formattedValueToString,
getFieldDisplayName,
getLinksSupplier,
GrafanaTheme2,
InterpolateFunction,
LinkModel,
PanelData,
@ -26,6 +24,8 @@ 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 { getStyles } from '../timeseries/TimeSeriesTooltip';
import { HeatmapData } from './fields';
import { renderHistogram } from './renderHistogram';
import { formatMilliseconds, getFieldFromData, getHoverCellColor, getSparseCellMinMax } from './tooltip/utils';
@ -112,7 +112,7 @@ const HeatmapHoverCell = ({
let nonNumericOrdinalDisplay: string | undefined = undefined;
let contentLabelValue: LabelValue[] = [];
let contentItems: LabelValue[] = [];
const getYValueIndex = (idx: number) => {
return idx % data.yBucketCount! ?? 0;
@ -244,7 +244,7 @@ const HeatmapHoverCell = ({
if (mode === TooltipDisplayMode.Single || isPinned) {
const fromToInt: LabelValue[] = interval ? [{ label: 'Duration', value: formatMilliseconds(interval) }] : [];
contentLabelValue = [
contentItems = [
{
label: getFieldDisplayName(countField, data.heatmap),
value: data.display!(count),
@ -272,7 +272,7 @@ const HeatmapHoverCell = ({
const vals: LabelValue[] = getDisplayData(fromIdx, toIdx);
vals.forEach((val) => {
contentLabelValue.push({
contentItems.push({
label: val.label,
value: val.value,
color: val.color ?? '#FFF',
@ -330,26 +330,17 @@ const HeatmapHoverCell = ({
[index]
);
const getHeaderLabel = (): LabelValue => {
return {
label: '',
value: xDisp(xBucketMax)!,
};
const headerLabel: LabelValue = {
label: '',
value: xDisp(xBucketMax!)!,
};
const getContentLabelValue = (): LabelValue[] => {
return contentLabelValue;
};
const getCustomContent = () => {
let content: ReactElement[] = [];
if (mode !== TooltipDisplayMode.Single) {
return content;
}
let customContent: ReactElement[] = [];
if (mode === TooltipDisplayMode.Single) {
// Histogram
if (showHistogram) {
content.push(
if (showHistogram && !isSparse) {
customContent.push(
<canvas
width={histCanWidth}
height={histCanHeight}
@ -361,7 +352,7 @@ const HeatmapHoverCell = ({
// Color scale
if (colorPalette && showColorScale) {
content.push(
customContent.push(
<ColorScale
colorPalette={colorPalette}
min={data.heatmapColors?.minValue!}
@ -371,28 +362,15 @@ const HeatmapHoverCell = ({
/>
);
}
return content;
};
}
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<VizTooltipHeader headerLabel={getHeaderLabel()} isPinned={isPinned} />
<VizTooltipContent
contentLabelValue={getContentLabelValue()}
customContent={getCustomContent()}
isPinned={isPinned}
/>
<VizTooltipHeader headerLabel={headerLabel} isPinned={isPinned} />
<VizTooltipContent contentLabelValue={contentItems} customContent={customContent} isPinned={isPinned} />
{isPinned && <VizTooltipFooter dataLinks={links} annotate={annotate} />}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',
}),
});

View File

@ -411,7 +411,7 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
name: 'Show histogram (Y axis)',
defaultValue: defaultOptions.tooltip.yHistogram,
category,
showIf: (opts) => opts.tooltip.mode !== TooltipDisplayMode.None,
showIf: (opts) => opts.tooltip.mode === TooltipDisplayMode.Single,
});
builder.addBooleanSwitch({
@ -419,7 +419,7 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
name: 'Show color scale',
defaultValue: defaultOptions.tooltip.showColorScale,
category,
showIf: (opts) => opts.tooltip.mode !== TooltipDisplayMode.None && config.featureToggles.newVizTooltips,
showIf: (opts) => opts.tooltip.mode === TooltipDisplayMode.Single && config.featureToggles.newVizTooltips,
});
builder.addNumberInput({

View File

@ -224,16 +224,16 @@ export const StateTimelinePanel = ({
return (
<StateTimelineTooltip2
data={frames ?? []}
frames={frames ?? []}
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
alignedData={alignedFrame}
seriesIdx={seriesIdx}
timeZone={timeZone}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}
annotate={enableAnnotationCreation ? annotate : undefined}
withDuration={true}
/>
);
}}

View File

@ -1,102 +1,58 @@
import { css } from '@emotion/css';
import React from 'react';
import {
arrayUtils,
DataFrame,
Field,
FieldType,
getDisplayProcessor,
getFieldDisplayName,
GrafanaTheme2,
LinkModel,
TimeRange,
TimeZone,
} from '@grafana/data';
import { Field, FieldType, getFieldDisplayName, LinkModel, TimeRange } from '@grafana/data';
import { SortOrder } from '@grafana/schema/dist/esm/common/common.gen';
import { TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui';
import { TooltipDisplayMode, useStyles2 } from '@grafana/ui';
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader';
import { ColorIndicator, ColorPlacement, LabelValue } from '@grafana/ui/src/components/VizTooltip/types';
import { LabelValue } from '@grafana/ui/src/components/VizTooltip/types';
import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils';
import { findNextStateIndex, fmtDuration } from 'app/core/components/TimelineChart/utils';
import { getDataLinks } from '../status-history/utils';
import { TimeSeriesTooltipProps, getStyles } from '../timeseries/TimeSeriesTooltip';
interface StateTimelineTooltip2Props {
data: DataFrame[];
alignedData: DataFrame;
dataIdxs: Array<number | null>;
seriesIdx: number | null | undefined;
isPinned: boolean;
timeZone?: TimeZone;
interface StateTimelineTooltip2Props extends TimeSeriesTooltipProps {
timeRange: TimeRange;
mode?: TooltipDisplayMode;
sortOrder?: SortOrder;
annotate?: () => void;
withDuration: boolean;
}
export const StateTimelineTooltip2 = ({
data,
alignedData,
frames,
seriesFrame,
dataIdxs,
seriesIdx,
timeZone,
timeRange,
mode = TooltipDisplayMode.Single,
sortOrder = SortOrder.None,
scrollable = false,
isPinned,
annotate,
timeRange,
withDuration,
}: StateTimelineTooltip2Props) => {
const styles = useStyles2(getStyles);
const theme = useTheme2();
const datapointIdx = seriesIdx != null ? dataIdxs[seriesIdx] : dataIdxs.find((idx) => idx != null);
const xField = seriesFrame.fields[0];
if (datapointIdx == null || seriesIdx == null) {
return null;
}
const dataIdx = seriesIdx != null ? dataIdxs[seriesIdx] : dataIdxs.find((idx) => idx != null);
const valueFieldsCount = data.reduce(
(acc, frame) => acc + frame.fields.filter((field) => field.type !== FieldType.time).length,
0
);
const xVal = xField.display!(xField.values[dataIdx!]).text;
/**
* 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;
}
mode = isPinned ? TooltipDisplayMode.Single : mode;
let contentLabelValue: LabelValue[] = [];
const contentItems = getContentItems(seriesFrame.fields, xField, dataIdxs, seriesIdx, mode, sortOrder);
const xField = alignedData.fields[0];
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
let links: Array<LinkModel<Field>> = [];
const from = xFieldFmt(xField.values[datapointIdx!]).text;
// Single mode
if (mode === TooltipDisplayMode.Single || isPinned) {
const field = alignedData.fields[seriesIdx!];
links = getDataLinks(field, datapointIdx);
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
const value = field.values[datapointIdx!];
const display = fieldFmt(value);
const nextStateIdx = findNextStateIndex(field, datapointIdx!);
// append duration in single mode
if (withDuration && mode === TooltipDisplayMode.Single) {
const field = seriesFrame.fields[seriesIdx!];
const nextStateIdx = findNextStateIndex(field, dataIdx!);
let nextStateTs;
if (nextStateIdx) {
nextStateTs = xField.values[nextStateIdx!];
}
const stateTs = xField.values[datapointIdx!];
const stateTs = xField.values[dataIdx!];
let duration: string;
if (nextStateTs) {
@ -106,88 +62,29 @@ export const StateTimelineTooltip2 = ({
duration = fmtDuration(to - stateTs);
}
const durationEntry: LabelValue[] = duration ? [{ label: 'Duration', value: duration }] : [];
contentLabelValue = [
{
label: getFieldDisplayName(field),
value: display.text,
color: display.color,
colorIndicator: ColorIndicator.value,
colorPlacement: ColorPlacement.trailing,
},
...durationEntry,
];
contentItems.push({ label: 'Duration', value: duration });
}
if (mode === TooltipDisplayMode.Multi && !isPinned) {
const fields = alignedData.fields;
const sortIdx: unknown[] = [];
let links: Array<LinkModel<Field>> = [];
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
if (
!field ||
field === xField ||
field.type === FieldType.time ||
field.config.custom?.hideFrom?.tooltip ||
field.config.custom?.hideFrom?.viz
) {
continue;
}
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
const v = field.values[dataIdxs[i]!];
const display = fieldFmt(v);
sortIdx.push(v);
contentLabelValue.push({
label: getFieldDisplayName(field),
value: display.text,
color: display.color,
colorIndicator: ColorIndicator.value,
colorPlacement: ColorPlacement.trailing,
isActive: seriesIdx === i,
});
}
if (sortOrder !== SortOrder.None) {
// create sort reference series array, as Array.sort() mutates the original array
const sortRef = [...contentLabelValue];
const sortFn = arrayUtils.sortValues(sortOrder);
contentLabelValue.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]);
});
}
if (seriesIdx != null) {
const field = seriesFrame.fields[seriesIdx];
const dataIdx = dataIdxs[seriesIdx]!;
links = getDataLinks(field, dataIdx);
}
const getHeaderLabel = (): LabelValue => {
return {
label: '',
value: from,
};
};
const getContentLabelValue = (): LabelValue[] => {
return contentLabelValue;
const headerItem: LabelValue = {
label: xField.type === FieldType.time ? '' : getFieldDisplayName(xField, seriesFrame, frames),
value: xVal,
};
return (
<div className={styles.wrapper}>
<VizTooltipHeader headerLabel={getHeaderLabel()} isPinned={isPinned} />
<VizTooltipContent contentLabelValue={getContentLabelValue()} isPinned={isPinned} />
{isPinned && <VizTooltipFooter dataLinks={links} annotate={annotate} />}
<div>
<div className={styles.wrapper}>
<VizTooltipHeader headerLabel={headerItem} isPinned={isPinned} />
<VizTooltipContent contentLabelValue={contentItems} isPinned={isPinned} scrollable={scrollable} />
{isPinned && <VizTooltipFooter dataLinks={links} annotate={annotate} />}
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',
}),
});

View File

@ -22,13 +22,13 @@ import {
TimelineMode,
} 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 { StatusHistoryTooltip } from './StatusHistoryTooltip';
import { StatusHistoryTooltip2 } from './StatusHistoryTooltip2';
import { Options } from './panelcfg.gen';
const TOOLTIP_OFFSET = 10;
@ -251,16 +251,17 @@ export const StatusHistoryPanel = ({
};
return (
<StatusHistoryTooltip2
data={frames ?? []}
<StateTimelineTooltip2
frames={frames ?? []}
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
alignedData={alignedFrame}
seriesIdx={seriesIdx}
timeZone={timeZone}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}
annotate={enableAnnotationCreation ? annotate : undefined}
withDuration={false}
/>
);
}}

View File

@ -1,158 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import {
DataFrame,
Field,
formattedValueToString,
getDisplayProcessor,
getFieldDisplayName,
GrafanaTheme2,
TimeZone,
LinkModel,
FieldType,
arrayUtils,
} from '@grafana/data';
import { SortOrder, TooltipDisplayMode } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader';
import { ColorIndicator, ColorPlacement, LabelValue } from '@grafana/ui/src/components/VizTooltip/types';
import { getDataLinks } from './utils';
interface StatusHistoryTooltipProps {
data: DataFrame[];
dataIdxs: Array<number | null>;
alignedData: DataFrame;
seriesIdx: number | null | undefined;
timeZone: TimeZone;
isPinned: boolean;
mode?: TooltipDisplayMode;
sortOrder?: SortOrder;
annotate?: () => void;
}
function fmt(field: Field, val: number): string {
if (field.display) {
return formattedValueToString(field.display(val));
}
return `${val}`;
}
export const StatusHistoryTooltip2 = ({
dataIdxs,
alignedData,
seriesIdx,
mode = TooltipDisplayMode.Single,
sortOrder = SortOrder.None,
isPinned,
annotate,
}: StatusHistoryTooltipProps) => {
const styles = useStyles2(getStyles);
const datapointIdx = seriesIdx != null ? dataIdxs[seriesIdx] : dataIdxs.find((idx) => idx != null);
if (datapointIdx == null || seriesIdx == null) {
return null;
}
let contentLabelValue: LabelValue[] = [];
const xField = alignedData.fields[0];
let links: Array<LinkModel<Field>> = [];
// Single mode
if (mode === TooltipDisplayMode.Single || isPinned) {
const field = alignedData.fields[seriesIdx!];
links = getDataLinks(field, datapointIdx);
const fieldFmt = field.display || getDisplayProcessor();
const value = field.values[datapointIdx!];
const display = fieldFmt(value);
contentLabelValue = [
{
label: getFieldDisplayName(field),
value: fmt(field, field.values[datapointIdx]),
color: display.color,
colorIndicator: ColorIndicator.value,
colorPlacement: ColorPlacement.trailing,
},
];
}
if (mode === TooltipDisplayMode.Multi && !isPinned) {
const frame = alignedData;
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.config.custom?.hideFrom?.tooltip ||
field.config.custom?.hideFrom?.viz
) {
continue;
}
const fieldFmt = field.display || getDisplayProcessor();
const v = field.values[datapointIdx!];
const display = fieldFmt(v);
sortIdx.push(v);
contentLabelValue.push({
label: getFieldDisplayName(field),
value: fmt(field, field.values[datapointIdx]),
color: display.color,
colorIndicator: ColorIndicator.value,
colorPlacement: ColorPlacement.trailing,
isActive: seriesIdx === i,
});
}
if (sortOrder !== SortOrder.None) {
// create sort reference series array, as Array.sort() mutates the original array
const sortRef = [...contentLabelValue];
const sortFn = arrayUtils.sortValues(sortOrder);
contentLabelValue.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]);
});
}
}
const getHeaderLabel = (): LabelValue => {
return {
label: '',
value: fmt(xField, xField.values[datapointIdx]),
};
};
const getContentLabelValue = () => {
return contentLabelValue;
};
return (
<div className={styles.wrapper}>
<VizTooltipHeader headerLabel={getHeaderLabel()} isPinned={isPinned} />
<VizTooltipContent contentLabelValue={getContentLabelValue()} isPinned={isPinned} />
{isPinned && <VizTooltipFooter dataLinks={links} annotate={annotate} />}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',
}),
});

View File

@ -1,31 +1,21 @@
import { css } from '@emotion/css';
import React from 'react';
import {
DataFrame,
FALLBACK_COLOR,
FieldType,
GrafanaTheme2,
formattedValueToString,
getDisplayProcessor,
LinkModel,
Field,
getFieldDisplayName,
arrayUtils,
} from '@grafana/data';
import { DataFrame, FieldType, LinkModel, Field, getFieldDisplayName } from '@grafana/data';
import { SortOrder, TooltipDisplayMode } from '@grafana/schema/dist/esm/common/common.gen';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui';
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader';
import { ColorIndicator, ColorPlacement, LabelValue } from '@grafana/ui/src/components/VizTooltip/types';
import { LabelValue } from '@grafana/ui/src/components/VizTooltip/types';
import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils';
import { getDataLinks } from '../status-history/utils';
// exemplar / annotation / time region hovering?
// add annotation UI / alert dismiss UI?
interface TimeSeriesTooltipProps {
export interface TimeSeriesTooltipProps {
frames?: DataFrame[];
// aligned series frame
seriesFrame: DataFrame;
@ -53,119 +43,47 @@ export const TimeSeriesTooltip = ({
isPinned,
annotate,
}: TimeSeriesTooltipProps) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const xField = seriesFrame.fields[0];
if (!xField) {
return null;
}
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, theme });
let xVal = xFieldFmt(xField!.values[dataIdxs[0]!]).text;
const xVal = xField.display!(xField.values[dataIdxs[0]!]).text;
const contentItems = getContentItems(
seriesFrame.fields,
xField,
dataIdxs,
seriesIdx,
mode,
sortOrder,
(field) => field.type === FieldType.number
);
let links: Array<LinkModel<Field>> = [];
let contentLabelValue: LabelValue[] = [];
// Single mode
if (mode === TooltipDisplayMode.Single) {
const field = seriesFrame.fields[seriesIdx!];
if (!field) {
return null;
}
const dataIdx = dataIdxs[seriesIdx!]!;
xVal = xFieldFmt(xField!.values[dataIdx]).text;
const fieldFmt = field.display || getDisplayProcessor({ field, theme });
const display = fieldFmt(field.values[dataIdx]);
if (seriesIdx != null) {
const field = seriesFrame.fields[seriesIdx];
const dataIdx = dataIdxs[seriesIdx]!;
links = getDataLinks(field, dataIdx);
contentLabelValue = [
{
label: getFieldDisplayName(field, seriesFrame, frames),
value: display ? formattedValueToString(display) : null,
color: display.color || FALLBACK_COLOR,
colorIndicator: ColorIndicator.series,
colorPlacement: ColorPlacement.first,
},
];
}
if (mode === TooltipDisplayMode.Multi) {
const fields = seriesFrame.fields;
const sortIdx: unknown[] = [];
for (let i = 0; i < fields.length; i++) {
const field = seriesFrame.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 = seriesFrame.fields[i].values[dataIdxs[i]!];
const display = field.display!(v); // super expensive :(
sortIdx.push(v);
contentLabelValue.push({
label: field.state?.displayName ?? field.name,
value: display ? formattedValueToString(display) : null,
color: display.color || FALLBACK_COLOR,
colorIndicator: ColorIndicator.series,
colorPlacement: ColorPlacement.first,
isActive: seriesIdx === i,
});
if (sortOrder !== SortOrder.None) {
// create sort reference series array, as Array.sort() mutates the original array
const sortRef = [...contentLabelValue];
const sortFn = arrayUtils.sortValues(sortOrder);
contentLabelValue.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]);
});
}
}
if (seriesIdx != null) {
const field = seriesFrame.fields[seriesIdx];
const dataIdx = dataIdxs[seriesIdx]!;
links = getDataLinks(field, dataIdx);
}
}
const getHeaderLabel = (): LabelValue => {
return {
label: xField.type === FieldType.time ? '' : getFieldDisplayName(xField, seriesFrame, frames),
value: xVal,
};
};
const getContentLabelValue = () => {
return contentLabelValue;
const headerItem: LabelValue = {
label: xField.type === FieldType.time ? '' : getFieldDisplayName(xField, seriesFrame, frames),
value: xVal,
};
return (
<div>
<div className={styles.wrapper}>
<VizTooltipHeader headerLabel={getHeaderLabel()} isPinned={isPinned} />
<VizTooltipContent contentLabelValue={getContentLabelValue()} isPinned={isPinned} scrollable={scrollable} />
<VizTooltipHeader headerLabel={headerItem} isPinned={isPinned} />
<VizTooltipContent contentLabelValue={contentItems} isPinned={isPinned} scrollable={scrollable} />
{isPinned && <VizTooltipFooter dataLinks={links} annotate={annotate} />}
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
export const getStyles = () => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',

View File

@ -1,7 +1,6 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataFrame, Field, getFieldDisplayName, GrafanaTheme2, LinkModel } from '@grafana/data';
import { DataFrame, Field, getFieldDisplayName, LinkModel } from '@grafana/data';
import { alpha } from '@grafana/data/src/themes/colorManipulator';
import { useStyles2 } from '@grafana/ui';
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
@ -10,8 +9,10 @@ import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizToolt
import { ColorIndicator, LabelValue } from '@grafana/ui/src/components/VizTooltip/types';
import { getTitleFromHref } from 'app/features/explore/utils/links';
import { getStyles } from '../timeseries/TimeSeriesTooltip';
import { Options } from './panelcfg.gen';
import { ScatterSeries, YValue } from './types';
import { ScatterSeries } from './types';
import { fmt } from './utils';
export interface Props {
@ -41,66 +42,46 @@ export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, allSeries, dismiss,
const xField = series.x(frame);
const yField = series.y(frame);
const getHeaderLabel = (): LabelValue => {
let label = series.name;
if (options.seriesMapping === 'manual') {
label = options.series?.[hoveredPointIndex]?.name ?? `Series ${hoveredPointIndex + 1}`;
}
let label = series.name;
if (options.seriesMapping === 'manual') {
label = options.series?.[hoveredPointIndex]?.name ?? `Series ${hoveredPointIndex + 1}`;
}
let colorThing = series.pointColor(frame);
let colorThing = series.pointColor(frame);
if (Array.isArray(colorThing)) {
colorThing = colorThing[rowIndex];
}
if (Array.isArray(colorThing)) {
colorThing = colorThing[rowIndex];
}
return {
label,
value: null,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
color: alpha(colorThing as string, 0.5),
colorIndicator: ColorIndicator.marker_md,
};
const headerItem: LabelValue = {
label,
value: '',
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
color: alpha(colorThing as string, 0.5),
colorIndicator: ColorIndicator.marker_md,
};
const getContentLabel = (): LabelValue[] => {
let colorThing = series.pointColor(frame);
const contentItems: LabelValue[] = [
{
label: getFieldDisplayName(xField, frame),
value: fmt(xField, xField.values[rowIndex]),
},
{
label: getFieldDisplayName(yField, frame),
value: fmt(yField, yField.values[rowIndex]),
},
];
if (Array.isArray(colorThing)) {
colorThing = colorThing[rowIndex];
}
const yValue: YValue = {
name: getFieldDisplayName(yField, frame),
val: yField.values[rowIndex],
field: yField,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
color: alpha(colorThing as string, 0.5),
};
const content: LabelValue[] = [
{
label: getFieldDisplayName(xField, frame),
value: fmt(xField, xField.values[rowIndex]),
},
{
label: yValue.name,
value: fmt(yValue.field, yValue.val),
},
];
// add extra fields
const extraFields: Field[] = frame.fields.filter((f) => f !== xField && f !== yField);
if (extraFields) {
extraFields.forEach((field) => {
content.push({
label: field.name,
value: fmt(field, field.values[rowIndex]),
});
// add extra fields
const extraFields: Field[] = frame.fields.filter((f) => f !== xField && f !== yField);
if (extraFields) {
extraFields.forEach((field) => {
contentItems.push({
label: field.name,
value: fmt(field, field.values[rowIndex]),
});
}
return content;
};
});
}
const getLinks = (): Array<LinkModel<Field>> => {
let links: Array<LinkModel<Field>> = [];
@ -120,16 +101,9 @@ export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, allSeries, dismiss,
return (
<div className={styles.wrapper}>
<VizTooltipHeader headerLabel={getHeaderLabel()} isPinned={isPinned} />
<VizTooltipContent contentLabelValue={getContentLabel()} isPinned={isPinned} />
<VizTooltipHeader headerLabel={headerItem} isPinned={isPinned} />
<VizTooltipContent contentLabelValue={contentItems} isPinned={isPinned} />
{isPinned && <VizTooltipFooter dataLinks={getLinks()} />}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',
}),
});

View File

@ -51,13 +51,6 @@ export interface ScatterSeries {
};
}
export interface YValue {
name: string;
val: number;
field: Field;
color: string;
}
export interface ExtraFacets {
colorFacetFieldName: string;
sizeFacetFieldName: string;