Tooltip: Improved Heatmap tooltip (#75712)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Adela Almasan 2023-11-30 11:04:56 -06:00 committed by GitHub
parent 5a80962de9
commit e361839261
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 682 additions and 175 deletions

View File

@ -1,5 +1,5 @@
// BETTERER RESULTS V2.
//
//
// If this file contains merge conflicts, use `betterer merge` to automatically resolve them:
// https://phenomnomnominal.github.io/betterer/docs/results-file/#merge
//
@ -6544,8 +6544,7 @@ exports[`better eslint`] = {
],
"public/app/plugins/panel/heatmap/HeatmapPanel.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/plugins/panel/heatmap/migrations.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]

View File

@ -124,10 +124,11 @@ Controls legend options
Controls tooltip options
| Property | Type | Required | Default | Description |
|--------------|---------|----------|---------|----------------------------------------------------------------|
| `show` | boolean | **Yes** | | Controls if the tooltip is shown |
| `yHistogram` | boolean | No | | Controls if the tooltip shows a histogram of the y-axis values |
| Property | Type | Required | Default | Description |
|------------------|---------|----------|---------|----------------------------------------------------------------|
| `show` | boolean | **Yes** | | Controls if the tooltip is shown |
| `showColorScale` | boolean | No | | Controls if the tooltip shows a color scale in header |
| `yHistogram` | boolean | No | | Controls if the tooltip shows a histogram of the y-axis values |
### Options

View File

@ -114,6 +114,7 @@ Use these settings to refine your visualization.
- **Show tooltip -** Show heatmap tooltip.
- **Show Histogram -** Show a Y-axis histogram on the tooltip. A histogram represents the distribution of the bucket values for a specific timestamp.
- **Show color scale -** Show a color scale on the tooltip. The color scale represents the mapping between bucket value and color. This option is configurable when you enable the `newVizTooltips` feature flag.
### Legend

View File

@ -133,6 +133,10 @@ export interface HeatmapTooltip {
* Controls if the tooltip is shown
*/
show: boolean;
/**
* Controls if the tooltip shows a color scale in header
*/
showColorScale?: boolean;
/**
* Controls if the tooltip shows a histogram of the y-axis values
*/
@ -264,6 +268,7 @@ export const defaultOptions: Partial<Options> = {
tooltip: {
show: true,
yHistogram: false,
showColorScale: false,
},
};

View File

@ -64,8 +64,8 @@ export const ColorScale = ({ colorPalette, min, max, display, hoverValue, useSto
{display && (
<div className={styles.followerContainer}>
<div className={styles.legendValues}>
<span>{display(min)}</span>
<span>{display(max)}</span>
<span className={styles.disabled}>{display(min)}</span>
<span className={styles.disabled}>{display(max)}</span>
</div>
{percent != null && (scaleHover.isShown || hoverValue !== undefined) && (
<span className={styles.hoverValue} style={{ left: `${percent}%` }}>
@ -135,8 +135,9 @@ const getStyles = (theme: GrafanaTheme2, colors: string[]) => ({
}),
scaleGradient: css({
background: `linear-gradient(90deg, ${colors.join()})`,
height: '10px',
height: '9px',
pointerEvents: 'none',
borderRadius: theme.shape.radius.default,
}),
legendValues: css({
display: 'flex',
@ -147,7 +148,6 @@ const getStyles = (theme: GrafanaTheme2, colors: string[]) => ({
position: 'absolute',
marginTop: '-14px',
padding: '3px 15px',
background: theme.colors.background.primary,
transform: 'translateX(-50%)',
}),
followerContainer: css({
@ -157,11 +157,14 @@ const getStyles = (theme: GrafanaTheme2, colors: string[]) => ({
}),
follower: css({
position: 'absolute',
height: '14px',
width: '14px',
height: '13px',
width: '13px',
borderRadius: theme.shape.radius.default,
transform: 'translateX(-50%) translateY(-50%)',
border: `2px solid ${theme.colors.text.primary}`,
marginTop: '5px',
top: '5px',
}),
disabled: css({
color: theme.colors.text.disabled,
}),
});

View File

@ -1,54 +1,86 @@
import React, { useEffect, useRef, useState } from 'react';
import { css } from '@emotion/css';
import React, { ReactElement, useEffect, useRef, useState } from 'react';
import uPlot from 'uplot';
import {
DataFrameType,
Field,
FieldType,
formattedValueToString,
getFieldDisplayName,
LinkModel,
TimeRange,
GrafanaTheme2,
getLinksSupplier,
InterpolateFunction,
ScopedVars,
PanelData,
LinkModel,
Field,
FieldType,
} from '@grafana/data';
import { HeatmapCellLayout } from '@grafana/schema';
import { LinkButton, VerticalGroup } 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, LabelValue } from '@grafana/ui/src/components/VizTooltip/types';
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
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 { HeatmapData } from './fields';
import { renderHistogram } from './renderHistogram';
import { HeatmapHoverEvent } from './utils';
import { getSparseCellMinMax, formatMilliseconds, getFieldFromData, getHoverCellColor } from './tooltip/utils';
type Props = {
data: HeatmapData;
hover: HeatmapHoverEvent;
interface Props {
dataIdxs: Array<number | null>;
seriesIdx: number | null | undefined;
dataRef: React.MutableRefObject<HeatmapData>;
showHistogram?: boolean;
timeRange: TimeRange;
showColorScale?: boolean;
isPinned: boolean;
dismiss: () => void;
canAnnotate: boolean;
panelData: PanelData;
replaceVars: InterpolateFunction;
scopedVars: ScopedVars[];
};
}
export const HeatmapHoverView = (props: Props) => {
if (props.hover.seriesIdx === 2) {
return <DataHoverView data={props.data.exemplars} rowIndex={props.hover.dataIdx} header={'Exemplar'} />;
if (props.seriesIdx === 2) {
return (
<DataHoverView
data={props.dataRef.current!.exemplars}
rowIndex={props.dataIdxs[2]}
header={'Exemplar'}
padding={8}
/>
);
}
return <HeatmapHoverCell {...props} />;
};
const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, replaceVars }: Props) => {
const index = hover.dataIdx;
const HeatmapHoverCell = ({
dataIdxs,
dataRef,
showHistogram,
isPinned,
canAnnotate,
panelData,
showColorScale = false,
scopedVars,
replaceVars,
dismiss,
}: Props) => {
const index = dataIdxs[1]!;
const data = dataRef.current;
const [isSparse] = useState(
() => data.heatmap?.meta?.type === DataFrameType.HeatmapCells && !isHeatmapCellsDense(data.heatmap)
);
const xField = data.heatmap?.fields[0];
const yField = data.heatmap?.fields[1];
const countField = data.heatmap?.fields[2];
const xField = getFieldFromData(data.heatmap!, 'x', isSparse)!;
const yField = getFieldFromData(data.heatmap!, 'y', isSparse)!;
const countField = getFieldFromData(data.heatmap!, 'count', isSparse)!;
const xDisp = (v: number) => {
if (xField?.display) {
@ -62,9 +94,9 @@ const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, repl
return `${v}`;
};
const xVals = xField?.values;
const yVals = yField?.values;
const countVals = countField?.values;
const xVals = xField.values;
const yVals = yField.values;
const countVals = countField.values;
// labeled buckets
const meta = readHeatmapRowsCustomMeta(data.heatmap);
@ -72,56 +104,62 @@ const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, repl
const yValueIdx = index % data.yBucketCount! ?? 0;
let interval = xField?.config.interval;
let yBucketMin: string;
let yBucketMax: string;
let nonNumericOrdinalDisplay: string | undefined = undefined;
if (meta.yOrdinalDisplay) {
const yMinIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx - 1 : yValueIdx;
const yMaxIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx : yValueIdx + 1;
yBucketMin = yMinIdx < 0 ? meta.yMinDisplay! : `${meta.yOrdinalDisplay[yMinIdx]}`;
yBucketMax = `${meta.yOrdinalDisplay[yMaxIdx]}`;
// e.g. "pod-xyz123"
if (!meta.yOrdinalLabel || Number.isNaN(+meta.yOrdinalLabel[0])) {
nonNumericOrdinalDisplay = data.yLayout === HeatmapCellLayout.le ? yBucketMax : yBucketMin;
}
} else {
const value = yVals?.[yValueIdx];
if (data.yLayout === HeatmapCellLayout.le) {
yBucketMax = `${value}`;
if (data.yLog) {
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
let exp = logFn(value) - 1 / data.yLogSplit!;
yBucketMin = `${data.yLog ** exp}`;
} else {
yBucketMin = `${value - data.yBucketSize!}`;
}
} else {
yBucketMin = `${value}`;
if (data.yLog) {
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
let exp = logFn(value) + 1 / data.yLogSplit!;
yBucketMax = `${data.yLog ** exp}`;
} else {
yBucketMax = `${value + data.yBucketSize!}`;
}
}
}
let xBucketMin: number;
let xBucketMax: number;
if (data.xLayout === HeatmapCellLayout.le) {
xBucketMax = xVals?.[index];
xBucketMin = xBucketMax - data.xBucketSize!;
let nonNumericOrdinalDisplay: string | undefined = undefined;
if (isSparse) {
({ xBucketMin, xBucketMax, yBucketMin, yBucketMax } = getSparseCellMinMax(data!, index));
} else {
xBucketMin = xVals?.[index];
xBucketMax = xBucketMin + data.xBucketSize!;
if (meta.yOrdinalDisplay) {
const yMinIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx - 1 : yValueIdx;
const yMaxIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx : yValueIdx + 1;
yBucketMin = yMinIdx < 0 ? meta.yMinDisplay! : `${meta.yOrdinalDisplay[yMinIdx]}`;
yBucketMax = `${meta.yOrdinalDisplay[yMaxIdx]}`;
// e.g. "pod-xyz123"
if (!meta.yOrdinalLabel || Number.isNaN(+meta.yOrdinalLabel[0])) {
nonNumericOrdinalDisplay = data.yLayout === HeatmapCellLayout.le ? yBucketMax : yBucketMin;
}
} else {
const value = yVals?.[yValueIdx];
if (data.yLayout === HeatmapCellLayout.le) {
yBucketMax = `${value}`;
if (data.yLog) {
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
let exp = logFn(value) - 1 / data.yLogSplit!;
yBucketMin = `${data.yLog ** exp}`;
} else {
yBucketMin = `${value - data.yBucketSize!}`;
}
} else {
yBucketMin = `${value}`;
if (data.yLog) {
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
let exp = logFn(value) + 1 / data.yLogSplit!;
yBucketMax = `${data.yLog ** exp}`;
} else {
yBucketMax = `${value + data.yBucketSize!}`;
}
}
}
if (data.xLayout === HeatmapCellLayout.le) {
xBucketMax = xVals[index];
xBucketMin = xBucketMax - data.xBucketSize!;
} else {
xBucketMin = xVals[index];
xBucketMax = xBucketMin + data.xBucketSize!;
}
}
const count = countVals?.[index];
@ -173,67 +211,109 @@ const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, repl
[index]
);
if (isSparse) {
return (
<div>
<DataHoverView data={data.heatmap} rowIndex={index} />
</div>
);
}
const { cellColor, colorPalette } = getHoverCellColor(data, index);
const renderYBucket = () => {
const getLabelValue = (): LabelValue[] => {
return [
{
label: getFieldDisplayName(countField, data.heatmap),
value: data.display!(count),
color: cellColor ?? '#FFF',
colorIndicator: ColorIndicator.value,
},
];
};
const getHeaderLabel = (): LabelValue => {
if (nonNumericOrdinalDisplay) {
return <div>Name: {nonNumericOrdinalDisplay}</div>;
return { label: 'Name', value: nonNumericOrdinalDisplay };
}
switch (data.yLayout) {
case HeatmapCellLayout.unknown:
return <div>{yDisp(yBucketMin)}</div>;
return { label: '', value: yDisp(yBucketMin) };
}
return (
<div>
Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
</div>
);
return {
label: 'Bucket',
value: `${yDisp(yBucketMin)}` + '-' + `${yDisp(yBucketMax)}`,
};
};
return (
<>
<div>
<div>{xDisp(xBucketMin)}</div>
{data.xLayout !== HeatmapCellLayout.unknown && <div>{xDisp(xBucketMax)}</div>}
</div>
{showHistogram && (
// Color scale
const getCustomValueDisplay = (): ReactElement | null => {
if (colorPalette && showColorScale) {
return (
<ColorScale
colorPalette={colorPalette}
min={data.heatmapColors?.minValue!}
max={data.heatmapColors?.maxValue!}
display={data.display}
hoverValue={count}
/>
);
}
return null;
};
const getContentLabelValue = (): LabelValue[] => {
let fromToInt = [
{
label: 'From',
value: xDisp(xBucketMin)!,
},
];
if (data.xLayout !== HeatmapCellLayout.unknown) {
fromToInt.push({ label: 'To', value: xDisp(xBucketMax)! });
if (interval) {
const formattedString = formatMilliseconds(interval);
fromToInt.push({ label: 'Interval', value: formattedString });
}
}
return fromToInt;
};
const getCustomContent = (): ReactElement | null => {
if (showHistogram) {
return (
<canvas
width={histCanWidth}
height={histCanHeight}
ref={can}
style={{ width: histCssWidth + 'px', height: histCssHeight + 'px' }}
/>
)}
<div>
{renderYBucket()}
<div>
{getFieldDisplayName(countField!, data.heatmap)}: {data.display!(count)}
</div>
</div>
{links.length > 0 && (
<VerticalGroup>
{links.map((link, i) => (
<LinkButton
key={i}
icon={'external-link-alt'}
target={link.target}
href={link.href}
onClick={link.onClick}
fill="text"
style={{ width: '100%' }}
>
{link.title}
</LinkButton>
))}
</VerticalGroup>
)}
</>
);
}
return null;
};
// @TODO remove this when adding annotations support
canAnnotate = false;
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<VizTooltipHeader
headerLabel={getHeaderLabel()}
keyValuePairs={getLabelValue()}
customValueDisplay={getCustomValueDisplay()}
/>
<VizTooltipContent contentLabelValue={getContentLabelValue()} customContent={getCustomContent()} />
{isPinned && <VizTooltipFooter dataLinks={links} canAnnotate={canAnnotate} />}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',
width: '280px',
}),
});

View File

@ -0,0 +1,239 @@
import React, { useEffect, useRef, useState } from 'react';
import uPlot from 'uplot';
import {
DataFrameType,
Field,
FieldType,
formattedValueToString,
getFieldDisplayName,
LinkModel,
TimeRange,
getLinksSupplier,
InterpolateFunction,
ScopedVars,
} from '@grafana/data';
import { HeatmapCellLayout } from '@grafana/schema';
import { LinkButton, VerticalGroup } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView';
import { HeatmapData } from './fields';
import { renderHistogram } from './renderHistogram';
import { HeatmapHoverEvent } from './utils';
type Props = {
data: HeatmapData;
hover: HeatmapHoverEvent;
showHistogram?: boolean;
timeRange: TimeRange;
replaceVars: InterpolateFunction;
scopedVars: ScopedVars[];
};
export const HeatmapHoverView = (props: Props) => {
if (props.hover.seriesIdx === 2) {
return <DataHoverView data={props.data.exemplars} rowIndex={props.hover.dataIdx} header={'Exemplar'} />;
}
return <HeatmapHoverCell {...props} />;
};
const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, replaceVars }: Props) => {
const index = hover.dataIdx;
const [isSparse] = useState(
() => data.heatmap?.meta?.type === DataFrameType.HeatmapCells && !isHeatmapCellsDense(data.heatmap)
);
const xField = data.heatmap?.fields[0];
const yField = data.heatmap?.fields[1];
const countField = data.heatmap?.fields[2];
const xDisp = (v: number) => {
if (xField?.display) {
return formattedValueToString(xField.display(v));
}
if (xField?.type === FieldType.time) {
const tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
const dashboard = getDashboardSrv().getCurrent();
return dashboard?.formatDate(v, tooltipTimeFormat);
}
return `${v}`;
};
const xVals = xField?.values;
const yVals = yField?.values;
const countVals = countField?.values;
// labeled buckets
const meta = readHeatmapRowsCustomMeta(data.heatmap);
const yDisp = yField?.display ? (v: string) => formattedValueToString(yField.display!(v)) : (v: string) => `${v}`;
const yValueIdx = index % data.yBucketCount! ?? 0;
let yBucketMin: string;
let yBucketMax: string;
let nonNumericOrdinalDisplay: string | undefined = undefined;
if (meta.yOrdinalDisplay) {
const yMinIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx - 1 : yValueIdx;
const yMaxIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx : yValueIdx + 1;
yBucketMin = yMinIdx < 0 ? meta.yMinDisplay! : `${meta.yOrdinalDisplay[yMinIdx]}`;
yBucketMax = `${meta.yOrdinalDisplay[yMaxIdx]}`;
// e.g. "pod-xyz123"
if (!meta.yOrdinalLabel || Number.isNaN(+meta.yOrdinalLabel[0])) {
nonNumericOrdinalDisplay = data.yLayout === HeatmapCellLayout.le ? yBucketMax : yBucketMin;
}
} else {
const value = yVals?.[yValueIdx];
if (data.yLayout === HeatmapCellLayout.le) {
yBucketMax = `${value}`;
if (data.yLog) {
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
let exp = logFn(value) - 1 / data.yLogSplit!;
yBucketMin = `${data.yLog ** exp}`;
} else {
yBucketMin = `${value - data.yBucketSize!}`;
}
} else {
yBucketMin = `${value}`;
if (data.yLog) {
let logFn = data.yLog === 2 ? Math.log2 : Math.log10;
let exp = logFn(value) + 1 / data.yLogSplit!;
yBucketMax = `${data.yLog ** exp}`;
} else {
yBucketMax = `${value + data.yBucketSize!}`;
}
}
}
let xBucketMin: number;
let xBucketMax: number;
if (data.xLayout === HeatmapCellLayout.le) {
xBucketMax = xVals?.[index];
xBucketMin = xBucketMax - data.xBucketSize!;
} else {
xBucketMin = xVals?.[index];
xBucketMax = xBucketMin + data.xBucketSize!;
}
const count = countVals?.[index];
const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip));
const links: Array<LinkModel<Field>> = [];
const linkLookup = new Set<string>();
for (const field of visibleFields ?? []) {
const hasLinks = field.config.links && field.config.links.length > 0;
if (hasLinks && data.heatmap) {
const appropriateScopedVars = scopedVars.find(
(scopedVar) =>
scopedVar && scopedVar.__dataContext && scopedVar.__dataContext.value.field.name === nonNumericOrdinalDisplay
);
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);
}
});
}
}
let can = useRef<HTMLCanvasElement>(null);
let histCssWidth = 264;
let histCssHeight = 64;
let histCanWidth = Math.round(histCssWidth * uPlot.pxRatio);
let histCanHeight = Math.round(histCssHeight * uPlot.pxRatio);
useEffect(
() => {
if (showHistogram && xVals != null && countVals != null) {
renderHistogram(can, histCanWidth, histCanHeight, xVals, countVals, index, data.yBucketCount!);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[index]
);
if (isSparse) {
return (
<div>
<DataHoverView data={data.heatmap} rowIndex={index} />
</div>
);
}
const renderYBucket = () => {
if (nonNumericOrdinalDisplay) {
return <div>Name: {nonNumericOrdinalDisplay}</div>;
}
switch (data.yLayout) {
case HeatmapCellLayout.unknown:
return <div>{yDisp(yBucketMin)}</div>;
}
return (
<div>
Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
</div>
);
};
return (
<>
<div>
<div>{xDisp(xBucketMin)}</div>
{data.xLayout !== HeatmapCellLayout.unknown && <div>{xDisp(xBucketMax)}</div>}
</div>
{showHistogram && (
<canvas
width={histCanWidth}
height={histCanHeight}
ref={can}
style={{ width: histCssWidth + 'px', height: histCssHeight + 'px' }}
/>
)}
<div>
{renderYBucket()}
<div>
{getFieldDisplayName(countField!, data.heatmap)}: {data.display!(count)}
</div>
</div>
{links.length > 0 && (
<VerticalGroup>
{links.map((link, i) => (
<LinkButton
key={i}
icon={'external-link-alt'}
target={link.target}
href={link.href}
onClick={link.onClick}
fill="text"
style={{ width: '100%' }}
>
{link.title}
</LinkButton>
))}
</VerticalGroup>
)}
</>
);
};

View File

@ -1,12 +1,23 @@
import { css } from '@emotion/css';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { DataFrame, DataFrameType, Field, getLinksSupplier, GrafanaTheme2, PanelProps, TimeRange } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import {
DataFrame,
DataFrameType,
Field,
getLinksSupplier,
GrafanaTheme2,
PanelProps,
ScopedVars,
TimeRange,
} from '@grafana/data';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { ScaleDistributionConfig } from '@grafana/schema';
import {
Portal,
ScaleDistribution,
TooltipPlugin2,
ZoomPlugin,
UPlotChart,
usePanelContext,
useStyles2,
@ -14,11 +25,13 @@ import {
VizLayout,
VizTooltipContainer,
} from '@grafana/ui';
import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { ExemplarModalHeader } from './ExemplarModalHeader';
import { HeatmapHoverView } from './HeatmapHoverView';
import { HeatmapHoverView as HeatmapHoverViewOld } from './HeatmapHoverViewOld';
import { prepareHeatmapData } from './fields';
import { quantizeScheme } from './palettes';
import { Options } from './types';
@ -41,10 +54,12 @@ export const HeatmapPanel = ({
}: HeatmapPanelProps) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const { sync } = usePanelContext();
const { sync, canAddAnnotations } = usePanelContext();
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
// necessary for enabling datalinks in hover view
let scopedVarsFromRawData = [];
let scopedVarsFromRawData: ScopedVars[] = [];
for (const series of data.series) {
for (const field of series.fields) {
if (field.state?.scopedVars) {
@ -149,12 +164,6 @@ export const HeatmapPanel = ({
eventBus,
onhover: onhover,
onclick: options.tooltip.show ? onclick : null,
onzoom: (evt) => {
const delta = evt.xMax - evt.xMin;
if (delta > 1) {
onChangeTimeRange({ from: evt.xMin, to: evt.xMax });
}
},
isToolTipOpen,
timeZone,
getTimeRange: () => timeRangeRef.current,
@ -212,42 +221,71 @@ export const HeatmapPanel = ({
);
}
const newVizTooltips = config.featureToggles.newVizTooltips ?? false;
return (
<>
<VizLayout width={width} height={height} legend={renderLegend()}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart config={builder} data={facets as any} width={vizWidth} height={vizHeight}>
{/*children ? children(config, alignedFrame) : null*/}
{!newVizTooltips && <ZoomPlugin config={builder} onZoom={onChangeTimeRange} />}
{newVizTooltips && options.tooltip.show && (
<TooltipPlugin2
config={builder}
hoverMode={TooltipHoverMode.xyOne}
queryZoom={onChangeTimeRange}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => {
return (
<HeatmapHoverView
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
dataRef={dataRef}
isPinned={isPinned}
dismiss={dismiss}
showHistogram={options.tooltip.yHistogram}
showColorScale={options.tooltip.showColorScale}
canAnnotate={enableAnnotationCreation}
panelData={data}
replaceVars={replaceVariables}
scopedVars={scopedVarsFromRawData}
/>
);
}}
/>
)}
</UPlotChart>
)}
</VizLayout>
<Portal>
{hover && options.tooltip.show && (
<VizTooltipContainer
position={{ x: hover.pageX, y: hover.pageY }}
offset={{ x: 10, y: 10 }}
allowPointerEvents={isToolTipOpen.current}
>
{shouldDisplayCloseButton && <ExemplarModalHeader onClick={onCloseToolTip} />}
<HeatmapHoverView
timeRange={timeRange}
data={info}
hover={hover}
showHistogram={options.tooltip.yHistogram}
replaceVars={replaceVariables}
scopedVars={scopedVarsFromRawData}
/>
</VizTooltipContainer>
)}
</Portal>
{!newVizTooltips && (
<Portal>
{hover && options.tooltip.show && (
<VizTooltipContainer
position={{ x: hover.pageX, y: hover.pageY }}
offset={{ x: 10, y: 10 }}
allowPointerEvents={isToolTipOpen.current}
>
{shouldDisplayCloseButton && <ExemplarModalHeader onClick={onCloseToolTip} />}
<HeatmapHoverViewOld
timeRange={timeRange}
data={info}
hover={hover}
showHistogram={options.tooltip.yHistogram}
replaceVars={replaceVariables}
scopedVars={scopedVarsFromRawData}
/>
</VizTooltipContainer>
)}
</Portal>
)}
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
colorScaleWrapper: css`
margin-left: 25px;
padding: 10px 0;
max-width: 300px;
`,
colorScaleWrapper: css({
marginLeft: '25px',
padding: '10px 0',
maxWidth: '300px',
}),
});

View File

@ -406,6 +406,14 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
showIf: (opts) => opts.tooltip.show,
});
builder.addBooleanSwitch({
path: 'tooltip.showColorScale',
name: 'Show color scale',
defaultValue: defaultOptions.tooltip.showColorScale,
category,
showIf: (opts) => opts.tooltip.show && config.featureToggles.newVizTooltips,
});
category = ['Legend'];
builder.addBooleanSwitch({
path: 'legend.show',

View File

@ -82,6 +82,8 @@ composableKinds: PanelCfg: lineage: {
show: bool
// Controls if the tooltip shows a histogram of the y-axis values
yHistogram?: bool
// Controls if the tooltip shows a color scale in header
showColorScale?: bool
} @cuetsy(kind="interface")
// Controls legend options
HeatmapLegend: {
@ -143,8 +145,9 @@ composableKinds: PanelCfg: lineage: {
}
// Controls tooltip options
tooltip: HeatmapTooltip | *{
show: true
yHistogram: false
show: true
yHistogram: false
showColorScale: false
}
// Controls exemplar options
exemplars: ExemplarConfig | *{

View File

@ -130,6 +130,10 @@ export interface HeatmapTooltip {
* Controls if the tooltip is shown
*/
show: boolean;
/**
* Controls if the tooltip shows a color scale in header
*/
showColorScale?: boolean;
/**
* Controls if the tooltip shows a histogram of the y-axis values
*/
@ -261,6 +265,7 @@ export const defaultOptions: Partial<Options> = {
tooltip: {
show: true,
yHistogram: false,
showColorScale: false,
},
};

View File

@ -0,0 +1,45 @@
import { formatMilliseconds } from './utils';
describe('heatmap tooltip utils', () => {
it('converts ms to appropriate unit', async () => {
let msToFormat = 10;
let formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('10 milliseconds');
msToFormat = 1000;
formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('1 second');
msToFormat = 1000 * 120;
formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('2 minutes');
msToFormat = 1000 * 60 * 60;
formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('1 hour');
msToFormat = 1000 * 60 * 60 * 24;
formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('1 day');
msToFormat = 1000 * 60 * 60 * 24 * 7 * 3;
formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('3 weeks');
msToFormat = 1000 * 60 * 60 * 24 * 7 * 4;
formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('4 weeks');
msToFormat = 1000 * 60 * 60 * 24 * 7 * 5;
formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('1 month');
msToFormat = 1000 * 60 * 60 * 24 * 365;
formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('1 year');
msToFormat = 1000 * 60 * 60 * 24 * 365 * 2;
formatted = formatMilliseconds(msToFormat);
expect(formatted).toBe('2 years');
});
});

View File

@ -0,0 +1,90 @@
import { DataFrame, Field } from '@grafana/data';
import { HeatmapData } from '../fields';
type BucketsMinMax = {
xBucketMin: number;
xBucketMax: number;
yBucketMin: string;
yBucketMax: string;
};
export const getHoverCellColor = (data: HeatmapData, index: number) => {
const colorPalette = data.heatmapColors?.palette!;
const colorIndex = data.heatmapColors?.values[index];
let cellColor: string | undefined = undefined;
if (colorIndex != null) {
cellColor = colorPalette[colorIndex];
}
return { cellColor, colorPalette };
};
const conversions: Record<string, number> = {
year: 1000 * 60 * 60 * 24 * 365,
month: 1000 * 60 * 60 * 24 * 30,
week: 1000 * 60 * 60 * 24 * 7,
day: 1000 * 60 * 60 * 24,
hour: 1000 * 60 * 60,
minute: 1000 * 60,
second: 1000,
millisecond: 1,
};
// @TODO: display "~ 1 year/month"?
export const formatMilliseconds = (milliseconds: number) => {
let value = 1;
let unit = 'millisecond';
for (unit in conversions) {
if (milliseconds >= conversions[unit]) {
value = Math.floor(milliseconds / conversions[unit]);
break;
}
}
const unitString = value === 1 ? unit : unit + 's';
return `${value} ${unitString}`;
};
export const getFieldFromData = (data: DataFrame, fieldType: string, isSparse: boolean) => {
let field: Field | undefined;
switch (fieldType) {
case 'x':
field = isSparse
? data?.fields.find(({ name }) => name === 'x' || name === 'xMin' || name === 'xMax')
: data?.fields[0];
break;
case 'y':
field = isSparse
? data?.fields.find(({ name }) => name === 'y' || name === 'yMin' || name === 'yMax')
: data?.fields[1];
break;
case 'count':
field = isSparse ? data?.fields.find(({ name }) => name === 'count') : data?.fields[2];
break;
}
return field;
};
export const getSparseCellMinMax = (data: HeatmapData, index: number): BucketsMinMax => {
let fields = data.heatmap!.fields;
let xMax = fields.find((f) => f.name === 'xMax')!;
let yMin = fields.find((f) => f.name === 'yMin')!;
let yMax = fields.find((f) => f.name === 'yMax')!;
let interval = xMax.config.interval!;
return {
xBucketMin: xMax.values[index] - interval,
xBucketMax: xMax.values[index],
yBucketMin: yMin.values[index],
yBucketMax: yMax.values[index],
};
};

View File

@ -64,7 +64,7 @@ interface PrepConfigOpts {
onhover?: null | ((evt?: HeatmapHoverEvent | null) => void);
onclick?: null | ((evt?: Object) => void);
onzoom?: null | ((evt: HeatmapZoomEvent) => void);
isToolTipOpen: MutableRefObject<boolean>;
isToolTipOpen?: MutableRefObject<boolean>;
timeZone: string;
getTimeRange: () => TimeRange;
exemplarColor: string;
@ -85,7 +85,6 @@ export function prepConfig(opts: PrepConfigOpts) {
eventBus,
onhover,
onclick,
onzoom,
isToolTipOpen,
timeZone,
getTimeRange,
@ -143,15 +142,6 @@ export function prepConfig(opts: PrepConfigOpts) {
);
});
onzoom &&
builder.addHook('setSelect', (u) => {
onzoom({
xMin: u.posToVal(u.select.left, xScaleKey),
xMax: u.posToVal(u.select.left + u.select.width, xScaleKey),
});
u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false);
});
if (isTime) {
// this is a tmp hack because in mode: 2, uplot does not currently call scales.x.range() for setData() calls
// scales.x.range() typically reads back from drilled-down panelProps.timeRange via getTimeRange()
@ -197,7 +187,7 @@ export function prepConfig(opts: PrepConfigOpts) {
payload.point[xScaleUnit] = u.posToVal(left!, xScaleKey);
eventBus.publish(hoverEvent);
if (!isToolTipOpen.current) {
if (!isToolTipOpen?.current) {
if (pendingOnleave) {
clearTimeout(pendingOnleave);
pendingOnleave = 0;
@ -214,7 +204,7 @@ export function prepConfig(opts: PrepConfigOpts) {
}
}
if (!isToolTipOpen.current) {
if (!isToolTipOpen?.current) {
// if tiles have gaps, reduce flashing / re-render (debounce onleave by 100ms)
if (!pendingOnleave) {
pendingOnleave = setTimeout(() => {