diff --git a/.betterer.results b/.betterer.results index bed4d33a06e..9b21879f02a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] diff --git a/docs/sources/developers/kinds/composable/heatmap/panelcfg/schema-reference.md b/docs/sources/developers/kinds/composable/heatmap/panelcfg/schema-reference.md index 89412c56f27..4427b6c99e9 100644 --- a/docs/sources/developers/kinds/composable/heatmap/panelcfg/schema-reference.md +++ b/docs/sources/developers/kinds/composable/heatmap/panelcfg/schema-reference.md @@ -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 diff --git a/docs/sources/panels-visualizations/visualizations/heatmap/index.md b/docs/sources/panels-visualizations/visualizations/heatmap/index.md index 08eb5f4be3b..1831a29aaf6 100644 --- a/docs/sources/panels-visualizations/visualizations/heatmap/index.md +++ b/docs/sources/panels-visualizations/visualizations/heatmap/index.md @@ -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 diff --git a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts index f1909d68ff0..4ed9a2f5450 100644 --- a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts @@ -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 = { tooltip: { show: true, yHistogram: false, + showColorScale: false, }, }; diff --git a/public/app/core/components/ColorScale/ColorScale.tsx b/public/app/core/components/ColorScale/ColorScale.tsx index 4fabd2bc4b8..5fd2039e1a8 100644 --- a/public/app/core/components/ColorScale/ColorScale.tsx +++ b/public/app/core/components/ColorScale/ColorScale.tsx @@ -64,8 +64,8 @@ export const ColorScale = ({ colorPalette, min, max, display, hoverValue, useSto {display && (
- {display(min)} - {display(max)} + {display(min)} + {display(max)}
{percent != null && (scaleHover.isShown || hoverValue !== undefined) && ( @@ -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, }), }); diff --git a/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx b/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx index 7ef653a2d5f..23a88663a3a 100644 --- a/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx @@ -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; + seriesIdx: number | null | undefined; + dataRef: React.MutableRefObject; 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 ; + if (props.seriesIdx === 2) { + return ( + + ); } + return ; }; -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; + let xBucketMin: number; + let xBucketMax: number; - 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]}`; + let nonNumericOrdinalDisplay: string | undefined = undefined; - // e.g. "pod-xyz123" - if (!meta.yOrdinalLabel || Number.isNaN(+meta.yOrdinalLabel[0])) { - nonNumericOrdinalDisplay = data.yLayout === HeatmapCellLayout.le ? yBucketMax : yBucketMin; - } + if (isSparse) { + ({ xBucketMin, xBucketMax, yBucketMin, yBucketMax } = getSparseCellMinMax(data!, index)); } 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!}`; + 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 { - yBucketMin = `${value}`; + const value = yVals?.[yValueIdx]; - if (data.yLog) { - let logFn = data.yLog === 2 ? Math.log2 : Math.log10; - let exp = logFn(value) + 1 / data.yLogSplit!; - yBucketMax = `${data.yLog ** exp}`; + 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 { - 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; - let xBucketMax: number; - - if (data.xLayout === HeatmapCellLayout.le) { - xBucketMax = xVals?.[index]; - xBucketMin = xBucketMax - data.xBucketSize!; - } else { - xBucketMin = xVals?.[index]; - xBucketMax = xBucketMin + data.xBucketSize!; + 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 ( -
- -
- ); - } + const { cellColor, colorPalette } = getHoverCellColor(data, index); + + const getLabelValue = (): LabelValue[] => { + return [ + { + label: getFieldDisplayName(countField, data.heatmap), + value: data.display!(count), + color: cellColor ?? '#FFF', + colorIndicator: ColorIndicator.value, + }, + ]; + }; - const renderYBucket = () => { + const getHeaderLabel = (): LabelValue => { if (nonNumericOrdinalDisplay) { - return
Name: {nonNumericOrdinalDisplay}
; + return { label: 'Name', value: nonNumericOrdinalDisplay }; } switch (data.yLayout) { case HeatmapCellLayout.unknown: - return
{yDisp(yBucketMin)}
; + return { label: '', value: yDisp(yBucketMin) }; } - return ( -
- Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)} -
- ); + + return { + label: 'Bucket', + value: `${yDisp(yBucketMin)}` + '-' + `${yDisp(yBucketMax)}`, + }; }; - return ( - <> -
-
{xDisp(xBucketMin)}
- {data.xLayout !== HeatmapCellLayout.unknown &&
{xDisp(xBucketMax)}
} -
- {showHistogram && ( + // Color scale + const getCustomValueDisplay = (): ReactElement | null => { + if (colorPalette && showColorScale) { + return ( + + ); + } + + 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 ( - )} -
- {renderYBucket()} -
- {getFieldDisplayName(countField!, data.heatmap)}: {data.display!(count)} -
-
- {links.length > 0 && ( - - {links.map((link, i) => ( - - {link.title} - - ))} - - )} - + ); + } + + return null; + }; + + // @TODO remove this when adding annotations support + canAnnotate = false; + + const styles = useStyles2(getStyles); + + return ( +
+ + + {isPinned && } +
); }; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css({ + display: 'flex', + flexDirection: 'column', + width: '280px', + }), +}); diff --git a/public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx b/public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx new file mode 100644 index 00000000000..7ef653a2d5f --- /dev/null +++ b/public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx @@ -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 ; + } + return ; +}; + +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> = []; + const linkLookup = new Set(); + + 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(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 ( +
+ +
+ ); + } + + const renderYBucket = () => { + if (nonNumericOrdinalDisplay) { + return
Name: {nonNumericOrdinalDisplay}
; + } + + switch (data.yLayout) { + case HeatmapCellLayout.unknown: + return
{yDisp(yBucketMin)}
; + } + return ( +
+ Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)} +
+ ); + }; + + return ( + <> +
+
{xDisp(xBucketMin)}
+ {data.xLayout !== HeatmapCellLayout.unknown &&
{xDisp(xBucketMax)}
} +
+ {showHistogram && ( + + )} +
+ {renderYBucket()} +
+ {getFieldDisplayName(countField!, data.heatmap)}: {data.display!(count)} +
+
+ {links.length > 0 && ( + + {links.map((link, i) => ( + + {link.title} + + ))} + + )} + + ); +}; diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index 1ea4c801ce8..45338861efa 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -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 ( <> {(vizWidth: number, vizHeight: number) => ( {/*children ? children(config, alignedFrame) : null*/} + {!newVizTooltips && } + {newVizTooltips && options.tooltip.show && ( + { + return ( + + ); + }} + /> + )} )} - - {hover && options.tooltip.show && ( - - {shouldDisplayCloseButton && } - - - )} - + {!newVizTooltips && ( + + {hover && options.tooltip.show && ( + + {shouldDisplayCloseButton && } + + + )} + + )} ); }; const getStyles = (theme: GrafanaTheme2) => ({ - colorScaleWrapper: css` - margin-left: 25px; - padding: 10px 0; - max-width: 300px; - `, + colorScaleWrapper: css({ + marginLeft: '25px', + padding: '10px 0', + maxWidth: '300px', + }), }); diff --git a/public/app/plugins/panel/heatmap/module.tsx b/public/app/plugins/panel/heatmap/module.tsx index 47f94f60fc6..a462cf6a52f 100644 --- a/public/app/plugins/panel/heatmap/module.tsx +++ b/public/app/plugins/panel/heatmap/module.tsx @@ -406,6 +406,14 @@ export const plugin = new PanelPlugin(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', diff --git a/public/app/plugins/panel/heatmap/panelcfg.cue b/public/app/plugins/panel/heatmap/panelcfg.cue index f6221de7470..687eefdb41e 100644 --- a/public/app/plugins/panel/heatmap/panelcfg.cue +++ b/public/app/plugins/panel/heatmap/panelcfg.cue @@ -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 | *{ diff --git a/public/app/plugins/panel/heatmap/panelcfg.gen.ts b/public/app/plugins/panel/heatmap/panelcfg.gen.ts index f54b0829897..7699ec9e120 100644 --- a/public/app/plugins/panel/heatmap/panelcfg.gen.ts +++ b/public/app/plugins/panel/heatmap/panelcfg.gen.ts @@ -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 = { tooltip: { show: true, yHistogram: false, + showColorScale: false, }, }; diff --git a/public/app/plugins/panel/heatmap/tooltip/tooltipUtils.test.ts b/public/app/plugins/panel/heatmap/tooltip/tooltipUtils.test.ts new file mode 100644 index 00000000000..4ef58686791 --- /dev/null +++ b/public/app/plugins/panel/heatmap/tooltip/tooltipUtils.test.ts @@ -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'); + }); +}); diff --git a/public/app/plugins/panel/heatmap/tooltip/utils.ts b/public/app/plugins/panel/heatmap/tooltip/utils.ts new file mode 100644 index 00000000000..78b633ade0b --- /dev/null +++ b/public/app/plugins/panel/heatmap/tooltip/utils.ts @@ -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 = { + 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], + }; +}; diff --git a/public/app/plugins/panel/heatmap/utils.ts b/public/app/plugins/panel/heatmap/utils.ts index db7b22d676f..d2997b46dc0 100644 --- a/public/app/plugins/panel/heatmap/utils.ts +++ b/public/app/plugins/panel/heatmap/utils.ts @@ -64,7 +64,7 @@ interface PrepConfigOpts { onhover?: null | ((evt?: HeatmapHoverEvent | null) => void); onclick?: null | ((evt?: Object) => void); onzoom?: null | ((evt: HeatmapZoomEvent) => void); - isToolTipOpen: MutableRefObject; + isToolTipOpen?: MutableRefObject; 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(() => {