mirror of https://github.com/grafana/grafana
Tooltip: Improved Heatmap tooltip (#75712)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com> Co-authored-by: nmarrs <nathanielmarrs@gmail.com>pull/78919/head
parent
5a80962de9
commit
e361839261
@ -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> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
@ -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], |
||||
}; |
||||
}; |
Loading…
Reference in new issue