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