mirror of
https://github.com/grafana/grafana.git
synced 2025-07-29 01:12:24 +08:00
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:
@ -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"]
|
||||||
|
@ -125,8 +125,9 @@ 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 |
|
||||||
|
| `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 |
|
| `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,11 +104,19 @@ 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 xBucketMin: number;
|
||||||
|
let xBucketMax: number;
|
||||||
|
|
||||||
let nonNumericOrdinalDisplay: string | undefined = undefined;
|
let nonNumericOrdinalDisplay: string | undefined = undefined;
|
||||||
|
|
||||||
|
if (isSparse) {
|
||||||
|
({ xBucketMin, xBucketMax, yBucketMin, yBucketMax } = getSparseCellMinMax(data!, index));
|
||||||
|
} else {
|
||||||
if (meta.yOrdinalDisplay) {
|
if (meta.yOrdinalDisplay) {
|
||||||
const yMinIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx - 1 : yValueIdx;
|
const yMinIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx - 1 : yValueIdx;
|
||||||
const yMaxIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx : yValueIdx + 1;
|
const yMaxIdx = data.yLayout === HeatmapCellLayout.le ? yValueIdx : yValueIdx + 1;
|
||||||
@ -113,16 +153,14 @@ const HeatmapHoverCell = ({ data, hover, showHistogram = false, scopedVars, repl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let xBucketMin: number;
|
|
||||||
let xBucketMax: number;
|
|
||||||
|
|
||||||
if (data.xLayout === HeatmapCellLayout.le) {
|
if (data.xLayout === HeatmapCellLayout.le) {
|
||||||
xBucketMax = xVals?.[index];
|
xBucketMax = xVals[index];
|
||||||
xBucketMin = xBucketMax - data.xBucketSize!;
|
xBucketMin = xBucketMax - data.xBucketSize!;
|
||||||
} else {
|
} else {
|
||||||
xBucketMin = xVals?.[index];
|
xBucketMin = xVals[index];
|
||||||
xBucketMax = xBucketMin + data.xBucketSize!;
|
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>
|
|
||||||
<DataHoverView data={data.heatmap} rowIndex={index} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
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)}`,
|
||||||
);
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<div>{xDisp(xBucketMin)}</div>
|
|
||||||
{data.xLayout !== HeatmapCellLayout.unknown && <div>{xDisp(xBucketMax)}</div>}
|
|
||||||
</div>
|
|
||||||
{showHistogram && (
|
|
||||||
<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)}
|
};
|
||||||
|
|
||||||
|
// @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>
|
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
wrapper: css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '280px',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
239
public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx
Normal file
239
public/app/plugins/panel/heatmap/HeatmapHoverViewOld.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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,15 +221,43 @@ 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>
|
||||||
|
{!newVizTooltips && (
|
||||||
<Portal>
|
<Portal>
|
||||||
{hover && options.tooltip.show && (
|
{hover && options.tooltip.show && (
|
||||||
<VizTooltipContainer
|
<VizTooltipContainer
|
||||||
@ -229,7 +266,7 @@ export const HeatmapPanel = ({
|
|||||||
allowPointerEvents={isToolTipOpen.current}
|
allowPointerEvents={isToolTipOpen.current}
|
||||||
>
|
>
|
||||||
{shouldDisplayCloseButton && <ExemplarModalHeader onClick={onCloseToolTip} />}
|
{shouldDisplayCloseButton && <ExemplarModalHeader onClick={onCloseToolTip} />}
|
||||||
<HeatmapHoverView
|
<HeatmapHoverViewOld
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
data={info}
|
data={info}
|
||||||
hover={hover}
|
hover={hover}
|
||||||
@ -240,14 +277,15 @@ export const HeatmapPanel = ({
|
|||||||
</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: {
|
||||||
@ -145,6 +147,7 @@ composableKinds: PanelCfg: lineage: {
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
90
public/app/plugins/panel/heatmap/tooltip/utils.ts
Normal file
90
public/app/plugins/panel/heatmap/tooltip/utils.ts
Normal 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],
|
||||||
|
};
|
||||||
|
};
|
@ -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(() => {
|
||||||
|
Reference in New Issue
Block a user