Tooltip: Improved Heatmap tooltip (#75712)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
pull/78919/head
Adela Almasan 2 years ago committed by GitHub
parent 5a80962de9
commit e361839261
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      .betterer.results
  2. 9
      docs/sources/developers/kinds/composable/heatmap/panelcfg/schema-reference.md
  3. 1
      docs/sources/panels-visualizations/visualizations/heatmap/index.md
  4. 5
      packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts
  5. 17
      public/app/core/components/ColorScale/ColorScale.tsx
  6. 292
      public/app/plugins/panel/heatmap/HeatmapHoverView.tsx
  7. 239
      public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx
  8. 106
      public/app/plugins/panel/heatmap/HeatmapPanel.tsx
  9. 8
      public/app/plugins/panel/heatmap/module.tsx
  10. 7
      public/app/plugins/panel/heatmap/panelcfg.cue
  11. 5
      public/app/plugins/panel/heatmap/panelcfg.gen.ts
  12. 45
      public/app/plugins/panel/heatmap/tooltip/tooltipUtils.test.ts
  13. 90
      public/app/plugins/panel/heatmap/tooltip/utils.ts
  14. 16
      public/app/plugins/panel/heatmap/utils.ts

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

@ -124,10 +124,11 @@ Controls legend options
Controls tooltip options Controls tooltip options
| Property | Type | Required | Default | Description | | Property | Type | Required | Default | Description |
|--------------|---------|----------|---------|----------------------------------------------------------------| |------------------|---------|----------|---------|----------------------------------------------------------------|
| `show` | boolean | **Yes** | | Controls if the tooltip is shown | | `show` | boolean | **Yes** | | Controls if the tooltip is shown |
| `yHistogram` | boolean | No | | Controls if the tooltip shows a histogram of the y-axis values | | `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 ### Options

@ -114,6 +114,7 @@ Use these settings to refine your visualization.
- **Show tooltip -** Show heatmap tooltip. - **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 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 ### Legend

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

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

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

@ -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>
)}
</>
);
};

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

@ -406,6 +406,14 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
showIf: (opts) => opts.tooltip.show, 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']; category = ['Legend'];
builder.addBooleanSwitch({ builder.addBooleanSwitch({
path: 'legend.show', path: 'legend.show',

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

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

@ -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');
});
});

@ -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],
};
};

@ -64,7 +64,7 @@ interface PrepConfigOpts {
onhover?: null | ((evt?: HeatmapHoverEvent | null) => void); onhover?: null | ((evt?: HeatmapHoverEvent | null) => void);
onclick?: null | ((evt?: Object) => void); onclick?: null | ((evt?: Object) => void);
onzoom?: null | ((evt: HeatmapZoomEvent) => void); onzoom?: null | ((evt: HeatmapZoomEvent) => void);
isToolTipOpen: MutableRefObject<boolean>; isToolTipOpen?: MutableRefObject<boolean>;
timeZone: string; timeZone: string;
getTimeRange: () => TimeRange; getTimeRange: () => TimeRange;
exemplarColor: string; exemplarColor: string;
@ -85,7 +85,6 @@ export function prepConfig(opts: PrepConfigOpts) {
eventBus, eventBus,
onhover, onhover,
onclick, onclick,
onzoom,
isToolTipOpen, isToolTipOpen,
timeZone, timeZone,
getTimeRange, 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) { if (isTime) {
// this is a tmp hack because in mode: 2, uplot does not currently call scales.x.range() for setData() calls // 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() // 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); payload.point[xScaleUnit] = u.posToVal(left!, xScaleKey);
eventBus.publish(hoverEvent); eventBus.publish(hoverEvent);
if (!isToolTipOpen.current) { if (!isToolTipOpen?.current) {
if (pendingOnleave) { if (pendingOnleave) {
clearTimeout(pendingOnleave); clearTimeout(pendingOnleave);
pendingOnleave = 0; 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 tiles have gaps, reduce flashing / re-render (debounce onleave by 100ms)
if (!pendingOnleave) { if (!pendingOnleave) {
pendingOnleave = setTimeout(() => { pendingOnleave = setTimeout(() => {

Loading…
Cancel
Save