VizTooltips: Heatmap fixes and improvements (#83876)

Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
This commit is contained in:
Leon Sorokin
2024-03-06 19:30:33 -06:00
committed by GitHub
parent 201f5d3ac9
commit d549a3aabb
9 changed files with 91 additions and 162 deletions

View File

@@ -85,7 +85,7 @@ export const getContentItems = (
): VizTooltipItem[] => {
let rows: VizTooltipItem[] = [];
let allNumeric = false;
let allNumeric = true;
for (let i = 0; i < fields.length; i++) {
const field = fields[i];

View File

@@ -39,7 +39,7 @@ export const ExemplarHoverView = ({ displayValues, links, header = 'Exemplar' }:
);
})}
</div>
{links && (
{links && links.length > 0 && (
<div className={styles.exemplarFooter}>
{links.map((link, i) => (
<LinkButton key={i} href={link.href} className={styles.linkButton}>

View File

@@ -9,9 +9,7 @@ import {
getFieldDisplayName,
LinkModel,
TimeRange,
getLinksSupplier,
InterpolateFunction,
ScopedVars,
} from '@grafana/data';
import { HeatmapCellLayout } from '@grafana/schema';
import { LinkButton, VerticalGroup } from '@grafana/ui';
@@ -19,6 +17,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 { getDataLinks } from '../status-history/utils';
import { HeatmapData } from './fields';
import { renderHistogram } from './renderHistogram';
import { HeatmapHoverEvent } from './utils';
@@ -29,7 +29,6 @@ type Props = {
showHistogram?: boolean;
timeRange: TimeRange;
replaceVars: InterpolateFunction;
scopedVars: ScopedVars[];
};
export const HeatmapHoverView = (props: Props) => {
@@ -39,7 +38,7 @@ export const HeatmapHoverView = (props: Props) => {
return <HeatmapHoverCell {...props} />;
};
const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, replaceVars }: Props) => {
const HeatmapHoverCell = ({ data, hover, showHistogram = false }: Props) => {
const index = hover.dataIdx;
const [isSparse] = useState(
@@ -70,7 +69,8 @@ const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, repl
const meta = readHeatmapRowsCustomMeta(data.heatmap);
const yDisp = yField?.display ? (v: string) => formattedValueToString(yField.display!(v)) : (v: string) => `${v}`;
const yValueIdx = index % data.yBucketCount! ?? 0;
const yValueIdx = index % (data.yBucketCount ?? 1);
const xValueIdx = Math.floor(index / (data.yBucketCount ?? 1));
let yBucketMin: string;
let yBucketMax: string;
@@ -126,33 +126,16 @@ const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, repl
const count = countVals?.[index];
const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip));
const links: Array<LinkModel<Field>> = [];
const linkLookup = new Set<string>();
let links: Array<LinkModel<Field>> = [];
for (const field of visibleFields ?? []) {
const hasLinks = field.config.links && field.config.links.length > 0;
const linksField = data.series?.fields[yValueIdx + 1];
if (hasLinks && data.heatmap) {
const appropriateScopedVars = scopedVars.find(
(scopedVar) =>
scopedVar && scopedVar.__dataContext && scopedVar.__dataContext.value.field.name === nonNumericOrdinalDisplay
);
if (linksField != null) {
const visible = !Boolean(linksField.config.custom?.hideFrom?.tooltip);
const hasLinks = (linksField.config.links?.length ?? 0) > 0;
field.getLinks = getLinksSupplier(data.heatmap, field, appropriateScopedVars || {}, replaceVars);
}
if (field.getLinks) {
const value = field.values[index];
const display = field.display ? field.display(value) : { text: `${value}`, numeric: +value };
field.getLinks({ calculatedValue: display, valueRowIndex: index }).forEach((link) => {
const key = `${link.title}/${link.href}`;
if (!linkLookup.has(key)) {
links.push(link);
linkLookup.add(key);
}
});
if (visible && hasLinks) {
links = getDataLinks(linksField, xValueIdx);
}
}

View File

@@ -1,17 +1,7 @@
import { css } from '@emotion/css';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
DashboardCursorSync,
DataFrame,
DataFrameType,
Field,
getLinksSupplier,
GrafanaTheme2,
PanelProps,
ScopedVars,
TimeRange,
} from '@grafana/data';
import { DashboardCursorSync, DataFrameType, GrafanaTheme2, PanelProps, TimeRange } from '@grafana/data';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { ScaleDistributionConfig } from '@grafana/schema';
import {
@@ -34,8 +24,8 @@ import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/tra
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
import { ExemplarModalHeader } from './ExemplarModalHeader';
import { HeatmapHoverView } from './HeatmapHoverView';
import { HeatmapHoverView as HeatmapHoverViewOld } from './HeatmapHoverViewOld';
import { HeatmapHoverView } from './HeatmapHoverViewOld';
import { HeatmapTooltip } from './HeatmapTooltip';
import { prepareHeatmapData } from './fields';
import { quantizeScheme } from './palettes';
import { Options } from './types';
@@ -70,50 +60,26 @@ export const HeatmapPanel = ({
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
// necessary for enabling datalinks in hover view
let scopedVarsFromRawData: ScopedVars[] = [];
for (const series of data.series) {
for (const field of series.fields) {
if (field.state?.scopedVars) {
scopedVarsFromRawData.push(field.state.scopedVars);
}
}
}
// ugh
let timeRangeRef = useRef<TimeRange>(timeRange);
timeRangeRef.current = timeRange;
const getFieldLinksSupplier = useCallback(
(exemplars: DataFrame, field: Field) => {
return getLinksSupplier(exemplars, field, field.state?.scopedVars ?? {}, replaceVariables);
},
[replaceVariables]
);
const palette = useMemo(() => quantizeScheme(options.color, theme), [options.color, theme]);
const info = useMemo(() => {
try {
return prepareHeatmapData(
data.series,
data.annotations,
options,
palette,
theme,
getFieldLinksSupplier,
replaceVariables
);
return prepareHeatmapData(data.series, data.annotations, options, palette, theme, replaceVariables);
} catch (ex) {
return { warning: `${ex}` };
}
}, [data.series, data.annotations, options, palette, theme, getFieldLinksSupplier, replaceVariables]);
}, [data.series, data.annotations, options, palette, theme, replaceVariables]);
const facets = useMemo(() => {
let exemplarsXFacet: number[] | undefined = []; // "Time" field
let exemplarsYFacet: Array<number | undefined> = [];
const meta = readHeatmapRowsCustomMeta(info.heatmap);
if (info.exemplars?.length) {
exemplarsXFacet = info.exemplars?.fields[0].values;
@@ -265,7 +231,7 @@ export const HeatmapPanel = ({
};
return (
<HeatmapHoverView
<HeatmapTooltip
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
@@ -275,8 +241,6 @@ export const HeatmapPanel = ({
showHistogram={options.tooltip.yHistogram}
showColorScale={options.tooltip.showColorScale}
panelData={data}
replaceVars={replaceVariables}
scopedVars={scopedVarsFromRawData}
annotate={enableAnnotationCreation ? annotate : undefined}
/>
);
@@ -308,13 +272,12 @@ export const HeatmapPanel = ({
allowPointerEvents={isToolTipOpen.current}
>
{shouldDisplayCloseButton && <ExemplarModalHeader onClick={onCloseToolTip} />}
<HeatmapHoverViewOld
<HeatmapHoverView
timeRange={timeRange}
data={info}
hover={hover}
showHistogram={options.tooltip.yHistogram}
replaceVars={replaceVariables}
scopedVars={scopedVarsFromRawData}
/>
</VizTooltipContainer>
)}

View File

@@ -1,4 +1,4 @@
import React, { ReactElement, useEffect, useRef, useState } from 'react';
import React, { ReactElement, useEffect, useRef, useState, ReactNode } from 'react';
import uPlot from 'uplot';
import {
@@ -7,11 +7,8 @@ import {
FieldType,
formattedValueToString,
getFieldDisplayName,
getLinksSupplier,
InterpolateFunction,
LinkModel,
PanelData,
ScopedVars,
} from '@grafana/data';
import { HeatmapCellLayout } from '@grafana/schema';
import { TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui';
@@ -24,13 +21,14 @@ 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 { getStyles } from '../timeseries/TimeSeriesTooltip';
import { HeatmapData } from './fields';
import { renderHistogram } from './renderHistogram';
import { formatMilliseconds, getFieldFromData, getHoverCellColor, getSparseCellMinMax } from './tooltip/utils';
interface Props {
interface HeatmapTooltipProps {
mode: TooltipDisplayMode;
dataIdxs: Array<number | null>;
seriesIdx: number | null | undefined;
@@ -40,12 +38,10 @@ interface Props {
isPinned: boolean;
dismiss: () => void;
panelData: PanelData;
replaceVars: InterpolateFunction;
scopedVars: ScopedVars[];
annotate?: () => void;
}
export const HeatmapHoverView = (props: Props) => {
export const HeatmapTooltip = (props: HeatmapTooltipProps) => {
if (props.seriesIdx === 2) {
return (
<DataHoverView
@@ -66,11 +62,9 @@ const HeatmapHoverCell = ({
showHistogram,
isPinned,
showColorScale = false,
scopedVars,
replaceVars,
mode,
annotate,
}: Props) => {
}: HeatmapTooltipProps) => {
const index = dataIdxs[1]!;
const data = dataRef.current;
@@ -114,11 +108,8 @@ const HeatmapHoverCell = ({
let contentItems: VizTooltipItem[] = [];
const getYValueIndex = (idx: number) => {
return idx % data.yBucketCount! ?? 0;
};
let yValueIdx = getYValueIndex(index);
const yValueIdx = index % (data.yBucketCount ?? 1);
const xValueIdx = Math.floor(index / (data.yBucketCount ?? 1));
const getData = (idx: number = index) => {
if (meta.yOrdinalDisplay) {
@@ -187,7 +178,6 @@ const HeatmapHoverCell = ({
if (isSparse) {
({ xBucketMin, xBucketMax, yBucketMin, yBucketMax } = getSparseCellMinMax(data!, idx));
} else {
yValueIdx = getYValueIndex(idx);
getData(idx);
}
@@ -283,34 +273,23 @@ const HeatmapHoverCell = ({
});
}
const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip));
const links: Array<LinkModel<Field>> = [];
const linkLookup = new Set<string>();
let footer: ReactNode;
for (const field of visibleFields ?? []) {
const hasLinks = field.config.links && field.config.links.length > 0;
if (isPinned) {
let links: Array<LinkModel<Field>> = [];
if (hasLinks && data.heatmap) {
const appropriateScopedVars = scopedVars.find(
(scopedVar) =>
scopedVar && scopedVar.__dataContext && scopedVar.__dataContext.value.field.name === nonNumericOrdinalDisplay
);
const linksField = data.series?.fields[yValueIdx + 1];
field.getLinks = getLinksSupplier(data.heatmap, field, appropriateScopedVars || {}, replaceVars);
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);
}
}
if (field.getLinks) {
const value = field.values[index];
const display = field.display ? field.display(value) : { text: `${value}`, numeric: +value };
field.getLinks({ calculatedValue: display, valueRowIndex: index }).forEach((link) => {
const key = `${link.title}/${link.href}`;
if (!linkLookup.has(key)) {
links.push(link);
linkLookup.add(key);
}
});
}
footer = <VizTooltipFooter dataLinks={links} annotate={annotate} />;
}
let can = useRef<HTMLCanvasElement>(null);
@@ -377,9 +356,7 @@ const HeatmapHoverCell = ({
</div>
))}
</VizTooltipContent>
{(links.length > 0 || isPinned) && (
<VizTooltipFooter dataLinks={links} annotate={isPinned ? annotate : undefined} />
)}
{footer}
</div>
);
};

View File

@@ -6,12 +6,11 @@ import {
FieldType,
formattedValueToString,
getDisplayProcessor,
getLinksSupplier,
GrafanaTheme2,
InterpolateFunction,
LinkModel,
outerJoinDataFrames,
ValueFormatter,
ValueLinkConfig,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { HeatmapCellLayout } from '@grafana/schema';
@@ -39,6 +38,8 @@ export interface HeatmapData {
maxValue: number;
};
series?: DataFrame; // the joined single frame for nonNumericOrdinalY data links
exemplars?: DataFrame; // optionally linked exemplars
exemplarColor?: string;
@@ -70,8 +71,7 @@ export function prepareHeatmapData(
options: Options,
palette: string[],
theme: GrafanaTheme2,
getFieldLinks?: (exemplars: DataFrame, field: Field) => (config: ValueLinkConfig) => Array<LinkModel<Field>>,
replaceVariables?: InterpolateFunction
replaceVariables: InterpolateFunction = (v) => v
): HeatmapData {
if (!frames?.length) {
return {};
@@ -81,11 +81,9 @@ export function prepareHeatmapData(
const exemplars = annotations?.find((f) => f.name === 'exemplar');
if (getFieldLinks) {
exemplars?.fields.forEach((field, index) => {
exemplars.fields[index].getLinks = getFieldLinks(exemplars, field);
});
}
exemplars?.fields.forEach((field) => {
field.getLinks = getLinksSupplier(exemplars, field, field.state?.scopedVars ?? {}, replaceVariables);
});
if (options.calculate) {
if (config.featureToggles.transformationsVariableSupport) {
@@ -138,7 +136,7 @@ export function prepareHeatmapData(
}
// Everything past here assumes a field for each row in the heatmap (buckets)
if (!rowsHeatmap) {
if (rowsHeatmap == null) {
if (frames.length > 1) {
let allNamesNumeric = frames.every(
(frame) => !Number.isNaN(parseSampleValue(frame.fields[1].state?.displayName!))
@@ -148,11 +146,10 @@ export function prepareHeatmapData(
frames.sort(sortSeriesByLabel);
}
rowsHeatmap = [
outerJoinDataFrames({
frames,
})!,
][0];
rowsHeatmap = outerJoinDataFrames({
frames,
keepDisplayNames: true,
})!;
} else {
let frame = frames[0];
let numberFields = frame.fields.filter((field) => field.type === FieldType.number);
@@ -171,18 +168,31 @@ export function prepareHeatmapData(
}
}
return getDenseHeatmapData(
rowsToCellsHeatmap({
unit: options.yAxis?.unit, // used to format the ordinal lookup values
decimals: options.yAxis?.decimals,
...options.rowsFrame,
frame: rowsHeatmap,
}),
exemplars,
options,
palette,
theme
);
// config data links
rowsHeatmap.fields.forEach((field) => {
if ((field.config.links?.length ?? 0) === 0) {
return;
}
// this expects that the tooltip is able to identify the field and rowIndex from a dense hovered index
field.getLinks = getLinksSupplier(rowsHeatmap!, field, field.state?.scopedVars ?? {}, replaceVariables);
});
return {
...getDenseHeatmapData(
rowsToCellsHeatmap({
unit: options.yAxis?.unit, // used to format the ordinal lookup values
decimals: options.yAxis?.decimals,
...options.rowsFrame,
frame: rowsHeatmap,
}),
exemplars,
options,
palette,
theme
),
series: rowsHeatmap,
};
}
const getSparseHeatmapData = (

View File

@@ -53,15 +53,7 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
// NOTE: this feels like overkill/expensive just to assert if we have an ordinal y
// can probably simplify without doing full dataprep
const palette = quantizeScheme(opts.color, config.theme2);
const v = prepareHeatmapData(
context.data,
undefined,
opts,
palette,
config.theme2,
undefined,
context.replaceVariables
);
const v = prepareHeatmapData(context.data, undefined, opts, palette, config.theme2);
isOrdinalY = readHeatmapRowsCustomMeta(v.heatmap).yOrdinalDisplay != null;
} catch {}
}

View File

@@ -557,7 +557,8 @@ export function prepConfig(opts: PrepConfigOpts) {
});
},
},
exemplarFillColor
exemplarFillColor,
dataRef.current.yLayout
),
theme,
scaleKey: '', // facets' scales used (above)
@@ -585,6 +586,10 @@ export function prepConfig(opts: PrepConfigOpts) {
return hRect && seriesIdx === hRect.sidx ? hRect.didx : null;
},
focus: {
prox: 1e3,
dist: (u, seriesIdx) => (hRect?.sidx === seriesIdx ? 0 : Infinity),
},
points: {
fill: 'rgba(255,255,255, 0.3)',
bbox: (u, seriesIdx) => {
@@ -744,7 +749,7 @@ export function heatmapPathsDense(opts: PathbuilderOpts) {
};
}
export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: string) {
export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: string, yLayout?: HeatmapCellLayout) {
return (u: uPlot, seriesIdx: number) => {
uPlot.orient(
u,
@@ -772,6 +777,8 @@ export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: strin
let fillPaths = [points];
let fillPalette = [exemplarColor ?? 'rgba(255,0,255,0.7)'];
let yShift = yLayout === HeatmapCellLayout.le ? -0.5 : yLayout === HeatmapCellLayout.ge ? 0.5 : 0;
for (let i = 0; i < dataX.length; i++) {
let yVal = dataY[i]!;
@@ -782,10 +789,7 @@ export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: strin
let isSparseHeatmap = scaleY.distr === 3 && scaleY.log === 2;
if (!isSparseHeatmap) {
yVal -= 0.5; // center vertically in bucket (when tiles are le)
// y-randomize vertically to distribute exemplars in same bucket at same time
let randSign = Math.round(Math.random()) * 2 - 1;
yVal += randSign * 0.5 * Math.random();
yVal += yShift;
}
let x = valToPosX(dataX[i], scaleX, xDim, xOff);

View File

@@ -56,7 +56,7 @@ export const TimeSeriesTooltip = ({
seriesIdx,
mode,
sortOrder,
(field) => field.type === FieldType.number
(field) => field.type === FieldType.number || field.type === FieldType.enum
);
let footer: ReactNode;