HeatmapNG: render exemplars (#49287)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Leon Sorokin 2022-05-21 21:13:28 -05:00 committed by GitHub
parent a3402641d6
commit 4f46c2f75f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 286 additions and 547 deletions

View File

@ -97,7 +97,8 @@ export function bucketsToScanlines(frame: DataFrame): DataFrame {
config: xField.config, config: xField.config,
}, },
{ {
name: 'yMax', // this name determines whether cells are drawn above, below, or centered on the values
name: yField.labels?.le != null ? 'yMax' : 'y',
type: FieldType.number, type: FieldType.number,
values: new ArrayVector(ys), values: new ArrayVector(ys),
config: yField.config, config: yField.config,

View File

@ -1,14 +1,6 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { import { DataFrameType, Field, FieldType, formattedValueToString, getFieldDisplayName, LinkModel } from '@grafana/data';
DataFrameType,
DataFrameView,
Field,
FieldType,
formattedValueToString,
getFieldDisplayName,
LinkModel,
} from '@grafana/data';
import { LinkButton, VerticalGroup } from '@grafana/ui'; import { LinkButton, VerticalGroup } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
@ -23,7 +15,15 @@ type Props = {
showHistogram?: boolean; showHistogram?: boolean;
}; };
export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => { export const HeatmapHoverView = (props: Props) => {
if (props.hover.seriesIdx === 2) {
return <DataHoverView data={props.data.exemplars} rowIndex={props.hover.dataIdx} />;
}
return <HeatmapHoverCell {...props} />;
};
const HeatmapHoverCell = ({ data, hover, showHistogram }: Props) => {
const index = hover.dataIdx;
const xField = data.heatmap?.fields[0]; const xField = data.heatmap?.fields[0];
const yField = data.heatmap?.fields[1]; const yField = data.heatmap?.fields[1];
const countField = data.heatmap?.fields[2]; const countField = data.heatmap?.fields[2];
@ -60,7 +60,7 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
}; };
} }
const yValueIdx = hover.index % data.yBucketCount! ?? 0; const yValueIdx = index % data.yBucketCount! ?? 0;
const yMinIdx = data.yLayout === BucketLayout.le ? yValueIdx - 1 : yValueIdx; const yMinIdx = data.yLayout === BucketLayout.le ? yValueIdx - 1 : yValueIdx;
const yMaxIdx = data.yLayout === BucketLayout.le ? yValueIdx : yValueIdx + 1; const yMaxIdx = data.yLayout === BucketLayout.le ? yValueIdx : yValueIdx + 1;
@ -68,10 +68,10 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
const yBucketMin = yDispSrc?.[yMinIdx]; const yBucketMin = yDispSrc?.[yMinIdx];
const yBucketMax = yDispSrc?.[yMaxIdx]; const yBucketMax = yDispSrc?.[yMaxIdx];
const xBucketMin = xVals?.[hover.index]; const xBucketMin = xVals?.[index];
const xBucketMax = xBucketMin + data.xBucketSize; const xBucketMax = xBucketMin + data.xBucketSize;
const count = countVals?.[hover.index]; const count = countVals?.[index];
const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip)); const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip));
const links: Array<LinkModel<Field>> = []; const links: Array<LinkModel<Field>> = [];
@ -80,10 +80,10 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
for (const field of visibleFields ?? []) { for (const field of visibleFields ?? []) {
// TODO: Currently always undefined? (getLinks) // TODO: Currently always undefined? (getLinks)
if (field.getLinks) { if (field.getLinks) {
const v = field.values.get(hover.index); const v = field.values.get(index);
const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v }; const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v };
field.getLinks({ calculatedValue: disp, valueRowIndex: hover.index }).forEach((link) => { field.getLinks({ calculatedValue: disp, valueRowIndex: index }).forEach((link) => {
const key = `${link.title}/${link.href}`; const key = `${link.title}/${link.href}`;
if (!linkLookup.has(key)) { if (!linkLookup.has(key)) {
links.push(link); links.push(link);
@ -106,9 +106,9 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
let histCtx = can.current?.getContext('2d'); let histCtx = can.current?.getContext('2d');
if (histCtx && xVals && yVals && countVals) { if (histCtx && xVals && yVals && countVals) {
let fromIdx = hover.index; let fromIdx = index;
while (xVals[fromIdx--] === xVals[hover.index]) {} while (xVals[fromIdx--] === xVals[index]) {}
fromIdx++; fromIdx++;
@ -135,7 +135,7 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
let pctY = c / maxCount; let pctY = c / maxCount;
let pctX = j / (data.yBucketCount! + 1); let pctX = j / (data.yBucketCount! + 1);
let p = i === hover.index ? pHov : pRest; let p = i === index ? pHov : pRest;
p.rect( p.rect(
Math.round(histCanWidth * pctX), Math.round(histCanWidth * pctX),
@ -160,37 +160,13 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[hover.index] [index]
); );
const renderExemplars = () => {
const exemplarIndex = data.exemplarsMappings?.lookup; //?.[hover.index];
if (!exemplarIndex || !data.exemplars) {
return null;
}
const ids = exemplarIndex[hover.index];
if (ids) {
const view = new DataFrameView(data.exemplars);
return (
<ul>
{ids.map((id) => (
<li key={id}>
<pre>{JSON.stringify(view.get(id), null, 2)}</pre>
</li>
))}
</ul>
);
}
// should not show anything... but for debugging
return <div>EXEMPLARS: {JSON.stringify(exemplarIndex)}</div>;
};
if (data.heatmap?.meta?.type === DataFrameType.HeatmapSparse) { if (data.heatmap?.meta?.type === DataFrameType.HeatmapSparse) {
return ( return (
<div> <div>
<DataHoverView data={data.heatmap} rowIndex={hover.index} /> <DataHoverView data={data.heatmap} rowIndex={index} />
</div> </div>
); );
} }
@ -210,14 +186,17 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
/> />
)} )}
<div> <div>
{data.yLayout === BucketLayout.unknown ? (
<div>{yDisp(yBucketMin)}</div>
) : (
<div> <div>
Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)} Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
</div> </div>
)}
<div> <div>
{getFieldDisplayName(countField!, data.heatmap)}: {count} {getFieldDisplayName(countField!, data.heatmap)}: {count}
</div> </div>
</div> </div>
{renderExemplars()}
{links.length > 0 && ( {links.length > 0 && (
<VerticalGroup> <VerticalGroup>
{links.map((link, i) => ( {links.map((link, i) => (

View File

@ -42,9 +42,28 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
} }
}, [data, options, theme]); }, [data, options, theme]);
const facets = useMemo(() => [null, info.heatmap?.fields.map((f) => f.values.toArray())], [info.heatmap]); const facets = useMemo(() => {
let exemplarsXFacet: number[] = []; // "Time" field
let exemplarsyFacet: number[] = [];
//console.log(facets); if (info.exemplars && info.matchByLabel) {
exemplarsXFacet = info.exemplars?.fields[0].values.toArray();
// ordinal/labeled heatmap-buckets?
const hasLabeledY = info.yLabelValues != null;
if (hasLabeledY) {
let matchExemplarsBy = info.exemplars?.fields
.find((field) => field.name === info.matchByLabel)!
.values.toArray();
exemplarsyFacet = matchExemplarsBy.map((label) => info.yLabelValues?.indexOf(label)) as number[];
} else {
exemplarsyFacet = info.exemplars?.fields[1].values.toArray() as number[]; // "Value" field
}
}
return [null, info.heatmap?.fields.map((f) => f.values.toArray()), [exemplarsXFacet, exemplarsyFacet]];
}, [info.heatmap, info.exemplars, info.yLabelValues, info.matchByLabel]);
const palette = useMemo(() => quantizeScheme(options.color, theme), [options.color, theme]); const palette = useMemo(() => quantizeScheme(options.color, theme), [options.color, theme]);
@ -95,6 +114,7 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
palette, palette,
cellGap: options.cellGap, cellGap: options.cellGap,
hideThreshold: options.hideThreshold, hideThreshold: options.hideThreshold,
exemplarColor: options.exemplars?.color ?? 'rgba(255,0,255,0.7)',
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, data.structureRev]); }, [options, data.structureRev]);
@ -111,8 +131,9 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
const { min, max } = reduceField({ field: countField, reducers: [ReducerID.min, ReducerID.max] }); const { min, max } = reduceField({ field: countField, reducers: [ReducerID.min, ReducerID.max] });
let hoverValue: number | undefined = undefined; let hoverValue: number | undefined = undefined;
if (hover && info.heatmap.fields) { // seriesIdx: 1 is heatmap layer; 2 is exemplar layer
hoverValue = countField.values.get(hover.index); if (hover && info.heatmap.fields && hover.seriesIdx === 1) {
hoverValue = countField.values.get(hover.dataIdx);
} }
return ( return (

View File

@ -1,6 +1,5 @@
import { createTheme, ArrayVector, DataFrameType, FieldType } from '@grafana/data'; import { createTheme } from '@grafana/data';
import { BucketLayout, getExemplarsMapping, HEATMAP_NOT_SCANLINES_ERROR } from './fields';
import { PanelOptions } from './models.gen'; import { PanelOptions } from './models.gen';
const theme = createTheme(); const theme = createTheme();
@ -13,312 +12,3 @@ describe('Heatmap data', () => {
expect(options).toBeDefined(); expect(options).toBeDefined();
}); });
}); });
describe('creating a heatmap data mapping', () => {
describe('generates a simple data mapping with orderly data', () => {
const mapping = getExemplarsMapping(
{
heatmap: {
name: 'test',
meta: {
type: DataFrameType.HeatmapScanlines,
},
fields: [
{
name: 'xMin',
type: FieldType.number,
config: {},
values: new ArrayVector([1]),
},
{
name: 'yMin',
type: FieldType.number,
config: {},
values: new ArrayVector([1, 4, 7]),
},
{
name: 'count',
type: FieldType.number,
config: {},
values: new ArrayVector([3, 3, 3]),
},
],
length: 3,
},
xBucketCount: 1,
xBucketSize: 9,
xLayout: BucketLayout.ge,
yBucketCount: 3,
yBucketSize: 3,
yLayout: BucketLayout.ge,
},
{
name: 'origdata',
fields: [
{
name: 'time',
type: FieldType.time,
config: {},
values: new ArrayVector([1, 2, 3, 4, 5, 6, 7, 8, 9]),
},
{
name: 'value',
type: FieldType.number,
config: {},
values: new ArrayVector([1, 2, 3, 4, 5, 6, 7, 8, 9]),
},
],
length: 2,
}
);
it('takes good data, and delivers a working data mapping', () => {
expect(mapping.lookup.length).toEqual(3);
expect(mapping.lookup[0]).toEqual([0, 1, 2]);
expect(mapping.lookup[1]).toEqual([3, 4, 5]);
expect(mapping.lookup[2]).toEqual([6, 7, 8]);
});
});
describe('generates a data mapping with less orderly data (counts are different)', () => {
const heatmap = {
heatmap: {
name: 'test',
meta: {
type: DataFrameType.HeatmapScanlines,
},
fields: [
{
name: 'xMin',
type: FieldType.number,
config: {},
values: new ArrayVector([1]),
},
{
name: 'yMin',
type: FieldType.number,
config: {},
values: new ArrayVector([1, 4, 7]),
},
{
name: 'count',
type: FieldType.number,
config: {},
values: new ArrayVector([2, 1, 6]),
},
],
length: 3,
},
xBucketCount: 1,
xBucketSize: 9,
xLayout: BucketLayout.ge,
yBucketCount: 3,
yBucketSize: 3,
yLayout: BucketLayout.ge,
};
const rawData = {
name: 'origdata',
fields: [
{
name: 'time',
type: FieldType.time,
config: {},
values: new ArrayVector([1, 2, 3, 4, 5, 6, 7, 8, 9]),
},
{
name: 'value',
type: FieldType.number,
config: {},
values: new ArrayVector([7, 1, 8, 7, 2, 7, 8, 8, 4]),
},
],
length: 2,
};
it("Puts data into the proper buckets, when we don't care about the count", () => {
// In this case, we are just finding proper values, but don't care if a values
// exists in the bucket in the original data or not. Therefore, we should see
// a value mapped into the second mapping bucket containing the value '8'.
const mapping = getExemplarsMapping(heatmap, rawData);
expect(mapping.lookup.length).toEqual(3);
expect(mapping.lookup[0]).toEqual([1, 4]);
expect(mapping.lookup[1]).toEqual([8]);
expect(mapping.lookup[2]).toEqual([0, 2, 3, 5, 6, 7]);
});
});
describe('Handles a larger data set that will not fill all buckets', () => {
const mapping = getExemplarsMapping(
{
heatmap: {
name: 'test',
meta: {
type: DataFrameType.HeatmapScanlines,
},
fields: [
{
name: 'xMin',
type: FieldType.number,
config: {},
values: new ArrayVector([1, 1, 1, 4, 4, 4, 7, 7, 7]),
},
{
name: 'yMin',
type: FieldType.number,
config: {},
values: new ArrayVector([1, 4, 7, 1, 4, 7, 1, 4, 7]),
},
{
name: 'count',
type: FieldType.number,
config: {},
values: new ArrayVector([0, 0, 2, 0, 0, 3, 0, 2, 0]),
},
],
length: 3,
},
xBucketCount: 3,
xBucketSize: 3,
xLayout: BucketLayout.ge,
yBucketCount: 3,
yBucketSize: 3,
yLayout: BucketLayout.ge,
},
{
name: 'origdata',
fields: [
{
name: 'time',
type: FieldType.time,
config: {},
values: new ArrayVector([1, 2, 3, 4, 5, 6, 7, 8, 9]),
},
{
name: 'value',
type: FieldType.number,
config: {},
values: new ArrayVector([8, 0, 8, 7, 7, 8, 6, 10, 6]),
},
],
length: 2,
}
);
it('Creates the data mapping correctly', () => {
expect(mapping.lookup.length).toEqual(9);
expect(mapping).toEqual({
lookup: [null, null, [0, 2], null, null, [3, 4, 5], null, [6, 8], null],
low: [1],
high: [7],
});
});
it('filters out minimum and maximum values', () => {
expect(mapping.lookup.flat()).not.toContainEqual(1);
expect(mapping.lookup.flat()).not.toContainEqual(10);
});
});
describe('Error scenarios', () => {
const heatmap = {
heatmap: {
name: 'test',
meta: {
type: DataFrameType.HeatmapBuckets,
},
fields: [
{
name: 'xMin',
type: FieldType.number,
config: {},
values: new ArrayVector([1]),
},
{
name: 'yMin',
type: FieldType.number,
config: {},
values: new ArrayVector([1, 4, 7]),
},
{
name: 'count',
type: FieldType.number,
config: {},
values: new ArrayVector([2, 0, 6]),
},
],
length: 3,
},
xBucketCount: 1,
xBucketSize: 9,
xLayout: BucketLayout.ge,
yBucketCount: 3,
yBucketSize: 3,
yLayout: BucketLayout.ge,
};
const rawData = {
name: 'origdata',
fields: [
{
name: 'time',
type: FieldType.time,
config: {},
values: new ArrayVector([1, 2, 3, 4, 5, 6, 7, 8, 9]),
},
{
name: 'value',
type: FieldType.number,
config: {},
values: new ArrayVector([7, 1, 8, 7, 2, 7, 8, 8, 4]),
},
],
length: 2,
};
it('Will not process heatmap buckets', () => {
expect(() =>
getExemplarsMapping(
{
...heatmap,
heatmap: {
...heatmap.heatmap,
meta: {
type: DataFrameType.HeatmapBuckets,
},
},
},
rawData
)
).toThrow(HEATMAP_NOT_SCANLINES_ERROR);
expect(() =>
getExemplarsMapping(
{
...heatmap,
heatmap: {
...heatmap.heatmap,
meta: {
type: DataFrameType.TimeSeriesWide,
},
},
},
rawData
)
).toThrow(HEATMAP_NOT_SCANLINES_ERROR);
expect(() =>
getExemplarsMapping(
{
...heatmap,
heatmap: {
...heatmap.heatmap,
meta: {
type: undefined,
},
},
},
rawData
)
).toThrow(HEATMAP_NOT_SCANLINES_ERROR);
});
});
});

View File

@ -1,41 +1,33 @@
import { import {
DataFrame, DataFrame,
DataFrameType, DataFrameType,
Field,
FieldType, FieldType,
formattedValueToString, formattedValueToString,
getDisplayProcessor, getDisplayProcessor,
getFieldDisplayName, getFieldDisplayName,
getValueFormat, getValueFormat,
GrafanaTheme2, GrafanaTheme2,
incrRoundDn, outerJoinDataFrames,
incrRoundUp,
PanelData, PanelData,
} from '@grafana/data'; } from '@grafana/data';
import { calculateHeatmapFromData, bucketsToScanlines } from 'app/features/transformers/calculateHeatmap/heatmap'; import { calculateHeatmapFromData, bucketsToScanlines } from 'app/features/transformers/calculateHeatmap/heatmap';
import { HeatmapSourceMode, PanelOptions } from './models.gen'; import { HeatmapMode, PanelOptions } from './models.gen';
export const enum BucketLayout { export const enum BucketLayout {
le = 'le', le = 'le',
ge = 'ge', ge = 'ge',
unknown = 'unknown', // unknown
} }
export interface HeatmapDataMapping {
lookup: Array<number[] | null>;
high: number[]; // index of values bigger than the max Y
low: number[]; // index of values less than the min Y
}
export const HEATMAP_NOT_SCANLINES_ERROR = 'A calculated heatmap was expected, but not found';
export interface HeatmapData { export interface HeatmapData {
// List of heatmap frames heatmap?: DataFrame; // data we will render
heatmap?: DataFrame; exemplars?: DataFrame; // optionally linked exemplars
exemplars?: DataFrame; exemplarColor?: string;
exemplarsMappings?: HeatmapDataMapping;
yAxisValues?: Array<number | string | null>; yAxisValues?: Array<number | string | null>;
yLabelValues?: string[]; // matched ordinally to yAxisValues
matchByLabel?: string; // e.g. le, pod, etc.
xBucketSize?: number; xBucketSize?: number;
yBucketSize?: number; yBucketSize?: number;
@ -54,113 +46,65 @@ export interface HeatmapData {
} }
export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme: GrafanaTheme2): HeatmapData { export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme: GrafanaTheme2): HeatmapData {
const frames = data.series; let frames = data.series;
if (!frames?.length) { if (!frames?.length) {
return {}; return {};
} }
const { source } = options; const { mode } = options;
const exemplars = data.annotations?.find((f) => f.name === 'exemplar'); const exemplars = data.annotations?.find((f) => f.name === 'exemplar');
if (source === HeatmapSourceMode.Calculate) { if (mode === HeatmapMode.Calculate) {
// TODO, check for error etc // TODO, check for error etc
return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), exemplars, theme); return getHeatmapData(calculateHeatmapFromData(frames, options.calculate ?? {}), exemplars, theme);
} }
let sparseCellsHeatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapSparse); // Check for known heatmap types
if (sparseCellsHeatmap) { let bucketHeatmap: DataFrame | undefined = undefined;
return getSparseHeatmapData(sparseCellsHeatmap, exemplars, theme); for (const frame of frames) {
switch (frame.meta?.type) {
case DataFrameType.HeatmapSparse:
return getSparseHeatmapData(frame, exemplars, theme);
case DataFrameType.HeatmapScanlines:
return getHeatmapData(frame, exemplars, theme);
case DataFrameType.HeatmapBuckets:
bucketHeatmap = frame; // the default format
}
} }
// Find a well defined heatmap // Everything past here assumes a field for each row in the heatmap (buckets)
let scanlinesHeatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapScanlines); if (!bucketHeatmap) {
if (scanlinesHeatmap) { if (frames.length > 1) {
return getHeatmapData(scanlinesHeatmap, exemplars, theme); bucketHeatmap = [
outerJoinDataFrames({
frames,
})!,
][0];
} else {
bucketHeatmap = frames[0];
}
} }
let bucketsHeatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapBuckets); // Some datasources return values in ascending order and require math to know the deltas
if (bucketsHeatmap) { if (mode === HeatmapMode.Accumulated) {
console.log('TODO, deaccumulate the values');
}
const yFields = bucketHeatmap.fields.filter((f) => f.type === FieldType.number);
const matchByLabel = Object.keys(yFields[0].labels ?? {})[0];
const scanlinesFrame = bucketsToScanlines(bucketHeatmap);
return { return {
yAxisValues: frames[0].fields.flatMap((field) => matchByLabel,
field.type === FieldType.number ? getFieldDisplayName(field) : [] yLabelValues: matchByLabel ? yFields.map((f) => f.labels?.[matchByLabel] ?? '') : undefined,
), yAxisValues: yFields.map((f) => getFieldDisplayName(f, bucketHeatmap, frames)),
...getHeatmapData(bucketsToScanlines(bucketsHeatmap), exemplars, theme), ...getHeatmapData(scanlinesFrame, exemplars, theme),
}; };
}
if (source === HeatmapSourceMode.Data) {
let first = frames[0];
if (first.meta?.type !== DataFrameType.HeatmapScanlines) {
first = bucketsToScanlines(frames[0]);
}
return getHeatmapData(first, exemplars, theme);
}
// TODO, check for error etc
return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), exemplars, theme);
} }
const getHeatmapFields = (dataFrame: DataFrame): Array<Field | undefined> => {
const xField: Field | undefined = dataFrame.fields.find((f) => f.name === 'xMin');
const yField: Field | undefined = dataFrame.fields.find((f) => f.name === 'yMin');
const countField: Field | undefined = dataFrame.fields.find((f) => f.name === 'count');
return [xField, yField, countField];
};
export const getExemplarsMapping = (heatmapData: HeatmapData, rawData: DataFrame): HeatmapDataMapping => {
if (heatmapData.heatmap?.meta?.type !== DataFrameType.HeatmapScanlines) {
throw HEATMAP_NOT_SCANLINES_ERROR;
}
const [fxs, fys] = getHeatmapFields(heatmapData.heatmap!);
if (!fxs || !fys) {
throw HEATMAP_NOT_SCANLINES_ERROR;
}
const mapping: HeatmapDataMapping = {
lookup: new Array(heatmapData.xBucketCount! * heatmapData.yBucketCount!).fill(null),
high: [],
low: [],
};
const xos: number[] | undefined = rawData.fields.find((f: Field) => f.type === 'time')?.values.toArray();
const yos: number[] | undefined = rawData.fields.find((f: Field) => f.type === 'number')?.values.toArray();
if (!xos || !yos) {
return mapping;
}
const xsmin = fxs.values.get(0);
const ysmin = fys.values.get(0);
const xsmax = fxs.values.get(fxs.values.length - 1) + heatmapData.xBucketSize!;
const ysmax = fys.values.get(fys.values.length - 1) + heatmapData.yBucketSize!;
xos.forEach((xo: number, i: number) => {
const yo = yos[i];
const xBucketIdx = Math.floor(incrRoundDn(incrRoundUp((xo - xsmin) / heatmapData.xBucketSize!, 1e-7), 1e-7));
const yBucketIdx = Math.floor(incrRoundDn(incrRoundUp((yo - ysmin) / heatmapData.yBucketSize!, 1e-7), 1e-7));
if (xo < xsmin || yo < ysmin) {
mapping.low.push(i);
return;
}
if (xo >= xsmax || yo >= ysmax) {
mapping.high.push(i);
return;
}
const index = xBucketIdx * heatmapData.yBucketCount! + yBucketIdx;
if (mapping.lookup[index] === null) {
mapping.lookup[index] = [];
}
mapping.lookup[index]?.push(i);
});
return mapping;
};
const getSparseHeatmapData = ( const getSparseHeatmapData = (
frame: DataFrame, frame: DataFrame,
exemplars: DataFrame | undefined, exemplars: DataFrame | undefined,
@ -217,6 +161,9 @@ const getHeatmapData = (frame: DataFrame, exemplars: DataFrame | undefined, them
// The "count" field // The "count" field
const disp = frame.fields[2].display ?? getValueFormat('short'); const disp = frame.fields[2].display ?? getValueFormat('short');
const xName = frame.fields[0].name;
const yName = frame.fields[1].name;
const data: HeatmapData = { const data: HeatmapData = {
heatmap: frame, heatmap: frame,
exemplars, exemplars,
@ -226,16 +173,11 @@ const getHeatmapData = (frame: DataFrame, exemplars: DataFrame | undefined, them
yBucketCount: yBinQty, yBucketCount: yBinQty,
// TODO: improve heuristic // TODO: improve heuristic
xLayout: frame.fields[0].name === 'xMax' ? BucketLayout.le : BucketLayout.ge, xLayout: xName === 'xMax' ? BucketLayout.le : xName === 'xMin' ? BucketLayout.ge : BucketLayout.unknown,
yLayout: frame.fields[1].name === 'yMax' ? BucketLayout.le : BucketLayout.ge, yLayout: yName === 'yMax' ? BucketLayout.le : yName === 'yMin' ? BucketLayout.ge : BucketLayout.unknown,
display: (v) => formattedValueToString(disp(v)), display: (v) => formattedValueToString(disp(v)),
}; };
if (exemplars) {
data.exemplarsMappings = getExemplarsMapping(data, exemplars);
console.log('EXEMPLARS', data.exemplarsMappings, data.exemplars);
}
return data; return data;
}; };

View File

@ -25,6 +25,16 @@ describe('Heatmap Migrations', () => {
"overrides": Array [], "overrides": Array [],
}, },
"options": Object { "options": Object {
"calculate": Object {
"xAxis": Object {
"mode": "count",
"value": "100",
},
"yAxis": Object {
"mode": "count",
"value": "20",
},
},
"cellGap": 2, "cellGap": 2,
"cellSize": 10, "cellSize": 10,
"color": Object { "color": Object {
@ -35,21 +45,14 @@ describe('Heatmap Migrations', () => {
"scheme": "BuGn", "scheme": "BuGn",
"steps": 256, "steps": 256,
}, },
"heatmap": Object { "exemplars": Object {
"xAxis": Object { "color": "rgba(255,0,255,0.7)",
"mode": "count",
"value": "100",
},
"yAxis": Object {
"mode": "count",
"value": "20",
},
}, },
"legend": Object { "legend": Object {
"show": true, "show": true,
}, },
"mode": "calculate",
"showValue": "never", "showValue": "never",
"source": "calculate",
"tooltip": Object { "tooltip": Object {
"show": true, "show": true,
"yHistogram": true, "yHistogram": true,

View File

@ -5,7 +5,7 @@ import {
HeatmapCalculationOptions, HeatmapCalculationOptions,
} from 'app/features/transformers/calculateHeatmap/models.gen'; } from 'app/features/transformers/calculateHeatmap/models.gen';
import { HeatmapSourceMode, PanelOptions, defaultPanelOptions, HeatmapColorMode } from './models.gen'; import { HeatmapMode, PanelOptions, defaultPanelOptions, HeatmapColorMode } from './models.gen';
import { colorSchemes } from './palettes'; import { colorSchemes } from './palettes';
/** /**
@ -29,28 +29,28 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS
overrides: [], overrides: [],
}; };
const source = angular.dataFormat === 'tsbuckets' ? HeatmapSourceMode.Data : HeatmapSourceMode.Calculate; const mode = angular.dataFormat === 'tsbuckets' ? HeatmapMode.Aggregated : HeatmapMode.Calculate;
const heatmap: HeatmapCalculationOptions = { const calculate: HeatmapCalculationOptions = {
...defaultPanelOptions.heatmap, ...defaultPanelOptions.calculate,
}; };
if (source === HeatmapSourceMode.Calculate) { if (mode === HeatmapMode.Calculate) {
if (angular.xBucketSize) { if (angular.xBucketSize) {
heatmap.xAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.xBucketSize}` }; calculate.xAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.xBucketSize}` };
} else if (angular.xBucketNumber) { } else if (angular.xBucketNumber) {
heatmap.xAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.xBucketNumber}` }; calculate.xAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.xBucketNumber}` };
} }
if (angular.yBucketSize) { if (angular.yBucketSize) {
heatmap.yAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.yBucketSize}` }; calculate.yAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.yBucketSize}` };
} else if (angular.xBucketNumber) { } else if (angular.xBucketNumber) {
heatmap.yAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.yBucketNumber}` }; calculate.yAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.yBucketNumber}` };
} }
} }
const options: PanelOptions = { const options: PanelOptions = {
source, mode,
heatmap, calculate,
color: { color: {
...defaultPanelOptions.color, ...defaultPanelOptions.color,
steps: 256, // best match with existing colors steps: 256, // best match with existing colors
@ -67,6 +67,9 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS
show: Boolean(angular.tooltip?.show), show: Boolean(angular.tooltip?.show),
yHistogram: Boolean(angular.tooltip?.showHistogram), yHistogram: Boolean(angular.tooltip?.showHistogram),
}, },
exemplars: {
...defaultPanelOptions.exemplars,
},
}; };
// Migrate color options // Migrate color options

View File

@ -8,10 +8,10 @@ import { HeatmapCalculationOptions } from 'app/features/transformers/calculateHe
export const modelVersion = Object.freeze([1, 0]); export const modelVersion = Object.freeze([1, 0]);
export enum HeatmapSourceMode { export enum HeatmapMode {
Auto = 'auto', Aggregated = 'agg',
Calculate = 'calculate', Calculate = 'calculate',
Data = 'data', // Use the data as is Accumulated = 'acc', // accumulated
} }
export enum HeatmapColorMode { export enum HeatmapColorMode {
@ -33,7 +33,6 @@ export interface HeatmapColorOptions {
steps: number; // 2-128 steps: number; // 2-128
// Clamp the colors to the value range // Clamp the colors to the value range
field?: string;
min?: number; min?: number;
max?: number; max?: number;
} }
@ -46,11 +45,15 @@ export interface HeatmapLegend {
show: boolean; show: boolean;
} }
export interface ExemplarConfig {
color: string;
}
export interface PanelOptions { export interface PanelOptions {
source: HeatmapSourceMode; mode: HeatmapMode;
color: HeatmapColorOptions; color: HeatmapColorOptions;
heatmap?: HeatmapCalculationOptions; calculate?: HeatmapCalculationOptions;
showValue: VisibilityMode; showValue: VisibilityMode;
cellGap?: number; // was cardPadding cellGap?: number; // was cardPadding
@ -62,10 +65,11 @@ export interface PanelOptions {
legend: HeatmapLegend; legend: HeatmapLegend;
tooltip: HeatmapTooltip; tooltip: HeatmapTooltip;
exemplars: ExemplarConfig;
} }
export const defaultPanelOptions: PanelOptions = { export const defaultPanelOptions: PanelOptions = {
source: HeatmapSourceMode.Auto, mode: HeatmapMode.Aggregated,
color: { color: {
mode: HeatmapColorMode.Scheme, mode: HeatmapColorMode.Scheme,
scheme: 'Oranges', scheme: 'Oranges',
@ -82,6 +86,9 @@ export const defaultPanelOptions: PanelOptions = {
legend: { legend: {
show: true, show: true,
}, },
exemplars: {
color: 'rgba(255,0,255,0.7)',
},
cellGap: 1, cellGap: 1,
}; };

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Field, FieldConfigProperty, FieldType, PanelPlugin } from '@grafana/data'; import { FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { GraphFieldConfig } from '@grafana/schema'; import { GraphFieldConfig } from '@grafana/schema';
import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
@ -8,13 +8,7 @@ import { addHeatmapCalculationOptions } from 'app/features/transformers/calculat
import { HeatmapPanel } from './HeatmapPanel'; import { HeatmapPanel } from './HeatmapPanel';
import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations'; import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations';
import { import { PanelOptions, defaultPanelOptions, HeatmapMode, HeatmapColorMode, HeatmapColorScale } from './models.gen';
PanelOptions,
defaultPanelOptions,
HeatmapSourceMode,
HeatmapColorMode,
HeatmapColorScale,
} from './models.gen';
import { colorSchemes, quantizeScheme } from './palettes'; import { colorSchemes, quantizeScheme } from './palettes';
import { HeatmapSuggestionsSupplier } from './suggestions'; import { HeatmapSuggestionsSupplier } from './suggestions';
@ -30,36 +24,25 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
let category = ['Heatmap']; let category = ['Heatmap'];
builder.addRadio({ builder.addRadio({
path: 'source', path: 'mode',
name: 'Source', name: 'Data',
defaultValue: HeatmapSourceMode.Auto, defaultValue: defaultPanelOptions.mode,
category, category,
settings: { settings: {
options: [ options: [
{ label: 'Auto', value: HeatmapSourceMode.Auto }, { label: 'Aggregated', value: HeatmapMode.Aggregated },
{ label: 'Calculate', value: HeatmapSourceMode.Calculate }, { label: 'Calculate', value: HeatmapMode.Calculate },
{ label: 'Raw data', description: 'The results are already heatmap buckets', value: HeatmapSourceMode.Data }, // { label: 'Accumulated', value: HeatmapMode.Accumulated, description: 'The query response values are accumulated' },
], ],
}, },
}); });
if (opts.source === HeatmapSourceMode.Calculate) { if (opts.mode === HeatmapMode.Calculate) {
addHeatmapCalculationOptions('heatmap.', builder, opts.heatmap, category); addHeatmapCalculationOptions('calculate.', builder, opts.calculate, category);
} }
category = ['Colors']; category = ['Colors'];
builder.addFieldNamePicker({
path: `color.field`,
name: 'Color with field',
category,
settings: {
filter: (f: Field) => f.type === FieldType.number,
noFieldsMessage: 'No numeric fields found',
placeholderText: 'Auto',
},
});
builder.addRadio({ builder.addRadio({
path: `color.mode`, path: `color.mode`,
name: 'Mode', name: 'Mode',
@ -84,7 +67,6 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
builder.addRadio({ builder.addRadio({
path: `color.scale`, path: `color.scale`,
name: 'Scale', name: 'Scale',
description: '',
defaultValue: defaultPanelOptions.color.scale, defaultValue: defaultPanelOptions.color.scale,
category, category,
settings: { settings: {
@ -141,7 +123,7 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
.addCustomEditor({ .addCustomEditor({
id: '__scale__', id: '__scale__',
path: `__scale__`, path: `__scale__`,
name: 'Scale', name: '',
category, category,
editor: () => { editor: () => {
const palette = quantizeScheme(opts.color, config.theme2); const palette = quantizeScheme(opts.color, config.theme2);
@ -240,5 +222,13 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
defaultValue: defaultPanelOptions.legend.show, defaultValue: defaultPanelOptions.legend.show,
category, category,
}); });
category = ['Exemplars'];
builder.addColorPicker({
path: 'exemplars.color',
name: 'Color',
defaultValue: defaultPanelOptions.exemplars.color,
category,
});
}) })
.setSuggestionsSupplier(new HeatmapSuggestionsSupplier()); .setSuggestionsSupplier(new HeatmapSuggestionsSupplier());

View File

@ -13,8 +13,8 @@ interface PathbuilderOpts {
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void; each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
gap?: number | null; gap?: number | null;
hideThreshold?: number; hideThreshold?: number;
xCeil?: boolean; xAlign?: -1 | 0 | 1;
yCeil?: boolean; yAlign?: -1 | 0 | 1;
disp: { disp: {
fill: { fill: {
values: (u: uPlot, seriesIndex: number) => number[]; values: (u: uPlot, seriesIndex: number) => number[];
@ -23,8 +23,13 @@ interface PathbuilderOpts {
}; };
} }
interface PointsBuilderOpts {
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
}
export interface HeatmapHoverEvent { export interface HeatmapHoverEvent {
index: number; seriesIdx: number;
dataIdx: number;
pageX: number; pageX: number;
pageY: number; pageY: number;
} }
@ -44,6 +49,7 @@ interface PrepConfigOpts {
timeZone: string; timeZone: string;
getTimeRange: () => TimeRange; getTimeRange: () => TimeRange;
palette: string[]; palette: string[];
exemplarColor: string;
cellGap?: number | null; // in css pixels cellGap?: number | null; // in css pixels
hideThreshold?: number; hideThreshold?: number;
} }
@ -66,6 +72,7 @@ export function prepConfig(opts: PrepConfigOpts) {
const pxRatio = devicePixelRatio; const pxRatio = devicePixelRatio;
let heatmapType = dataRef.current?.heatmap?.meta?.type; let heatmapType = dataRef.current?.heatmap?.meta?.type;
const exemplarFillColor = theme.visualization.getColorByName(opts.exemplarColor);
let qt: Quadtree; let qt: Quadtree;
let hRect: Rect | null; let hRect: Rect | null;
@ -143,7 +150,8 @@ export function prepConfig(opts: PrepConfigOpts) {
} }
onhover({ onhover({
index: sel, seriesIdx: i,
dataIdx: sel,
pageX: rect.left + u.cursor.left!, pageX: rect.left + u.cursor.left!,
pageY: rect.top + u.cursor.top!, pageY: rect.top + u.cursor.top!,
}); });
@ -209,13 +217,16 @@ export function prepConfig(opts: PrepConfigOpts) {
range: shouldUseLogScale range: shouldUseLogScale
? undefined ? undefined
: (u, dataMin, dataMax) => { : (u, dataMin, dataMax) => {
let bucketSize = dataRef.current?.yBucketSize; const bucketSize = dataRef.current?.yBucketSize;
if (bucketSize) { if (bucketSize) {
if (dataRef.current?.yLayout === BucketLayout.le) { if (dataRef.current?.yLayout === BucketLayout.le) {
dataMin -= bucketSize!; dataMin -= bucketSize!;
} else { } else if (dataRef.current?.yLayout === BucketLayout.ge) {
dataMax += bucketSize!; dataMax += bucketSize!;
} else {
dataMin -= bucketSize! / 2;
dataMax += bucketSize! / 2;
} }
} else { } else {
// how to expand scale range if inferred non-regular or log buckets? // how to expand scale range if inferred non-regular or log buckets?
@ -233,10 +244,10 @@ export function prepConfig(opts: PrepConfigOpts) {
theme: theme, theme: theme,
splits: hasLabeledY splits: hasLabeledY
? () => { ? () => {
let ys = dataRef.current?.heatmap?.fields[1].values.toArray()!; const ys = dataRef.current?.heatmap?.fields[1].values.toArray()!;
let splits = ys.slice(0, ys.length - ys.lastIndexOf(ys[0])); const splits = ys.slice(0, ys.length - ys.lastIndexOf(ys[0]));
let bucketSize = dataRef.current?.yBucketSize!; const bucketSize = dataRef.current?.yBucketSize!;
if (dataRef.current?.yLayout === BucketLayout.le) { if (dataRef.current?.yLayout === BucketLayout.le) {
splits.unshift(ys[0] - bucketSize); splits.unshift(ys[0] - bucketSize);
@ -249,11 +260,11 @@ export function prepConfig(opts: PrepConfigOpts) {
: undefined, : undefined,
values: hasLabeledY values: hasLabeledY
? () => { ? () => {
let yAxisValues = dataRef.current?.yAxisValues?.slice()!; const yAxisValues = dataRef.current?.yAxisValues?.slice()!;
if (dataRef.current?.yLayout === BucketLayout.le) { if (dataRef.current?.yLayout === BucketLayout.le) {
yAxisValues.unshift('0.0'); // assumes dense layout where lowest bucket's low bound is 0-ish yAxisValues.unshift('0.0'); // assumes dense layout where lowest bucket's low bound is 0-ish
} else { } else if (dataRef.current?.yLayout === BucketLayout.ge) {
yAxisValues.push('+Inf'); yAxisValues.push('+Inf');
} }
@ -262,8 +273,9 @@ export function prepConfig(opts: PrepConfigOpts) {
: undefined, : undefined,
}); });
let pathBuilder = heatmapType === DataFrameType.HeatmapScanlines ? heatmapPathsDense : heatmapPathsSparse; const pathBuilder = heatmapType === DataFrameType.HeatmapScanlines ? heatmapPathsDense : heatmapPathsSparse;
// heatmap layer
builder.addSeries({ builder.addSeries({
facets: [ facets: [
{ {
@ -289,8 +301,8 @@ export function prepConfig(opts: PrepConfigOpts) {
}, },
gap: cellGap, gap: cellGap,
hideThreshold, hideThreshold,
xCeil: dataRef.current?.xLayout === BucketLayout.le, xAlign: dataRef.current?.xLayout === BucketLayout.le ? -1 : dataRef.current?.xLayout === BucketLayout.ge ? 1 : 0,
yCeil: dataRef.current?.yLayout === BucketLayout.le, yAlign: dataRef.current?.yLayout === BucketLayout.le ? -1 : dataRef.current?.yLayout === BucketLayout.ge ? 1 : 0,
disp: { disp: {
fill: { fill: {
values: (u, seriesIdx) => { values: (u, seriesIdx) => {
@ -305,6 +317,38 @@ export function prepConfig(opts: PrepConfigOpts) {
scaleKey: '', // facets' scales used (above) scaleKey: '', // facets' scales used (above)
}); });
// exemplars layer
builder.addSeries({
facets: [
{
scale: 'x',
auto: true,
sorted: 1,
},
{
scale: 'y',
auto: true,
},
],
pathBuilder: heatmapPathsPoints(
{
each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => {
qt.add({
x: x - u.bbox.left,
y: y - u.bbox.top,
w: xSize,
h: ySize,
sidx: seriesIdx,
didx: dataIdx,
});
},
},
exemplarFillColor
) as any,
theme,
scaleKey: '', // facets' scales used (above)
});
builder.setCursor({ builder.setCursor({
drag: { drag: {
x: true, x: true,
@ -348,7 +392,7 @@ export function prepConfig(opts: PrepConfigOpts) {
const CRISP_EDGES_GAP_MIN = 4; const CRISP_EDGES_GAP_MIN = 4;
export function heatmapPathsDense(opts: PathbuilderOpts) { export function heatmapPathsDense(opts: PathbuilderOpts) {
const { disp, each, gap = 1, hideThreshold = 0, xCeil = false, yCeil = false } = opts; const { disp, each, gap = 1, hideThreshold = 0, xAlign = 1, yAlign = 1 } = opts;
const pxRatio = devicePixelRatio; const pxRatio = devicePixelRatio;
@ -408,8 +452,8 @@ export function heatmapPathsDense(opts: PathbuilderOpts) {
// let xCeil = false; // let xCeil = false;
// let yCeil = false; // let yCeil = false;
let xOffset = xCeil ? -xSize : 0; let xOffset = xAlign === -1 ? -xSize : xAlign === 0 ? -xSize / 2 : 0;
let yOffset = yCeil ? 0 : -ySize; let yOffset = yAlign === 1 ? -ySize : yAlign === 0 ? -ySize / 2 : 0;
// pre-compute x and y offsets // pre-compute x and y offsets
let cys = ys.slice(0, yBinQty).map((y) => round(valToPosY(y, scaleY, yDim, yOff) + yOffset)); let cys = ys.slice(0, yBinQty).map((y) => round(valToPosY(y, scaleY, yDim, yOff) + yOffset));
@ -453,6 +497,65 @@ export function heatmapPathsDense(opts: PathbuilderOpts) {
}; };
} }
export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: string) {
return (u: uPlot, seriesIdx: number) => {
uPlot.orient(
u,
seriesIdx,
(
series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
moveTo,
lineTo,
rect,
arc
) => {
//console.time('heatmapPathsSparse');
[dataX, dataY] = dataY as unknown as number[][];
let points = new Path2D();
let fillPaths = [points];
let fillPalette = [exemplarColor ?? 'rgba(255,0,255,0.7)'];
for (let i = 0; i < dataX.length; i++) {
let yVal = dataY[i]!;
yVal -= 0.5; // center vertically in bucket (when tiles are le)
// y-randomize vertically to distribute exemplars in same bucket at same time
let randSign = Math.round(Math.random()) * 2 - 1;
yVal += randSign * 0.5 * Math.random();
let x = valToPosX(dataX[i], scaleX, xDim, xOff);
let y = valToPosY(yVal, scaleY, yDim, yOff);
let w = 8;
let h = 8;
rect(points, x - w / 2, y - h / 2, w, h);
opts.each(u, seriesIdx, i, x - w / 2, y - h / 2, w, h);
}
u.ctx.save();
u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
u.ctx.clip();
fillPaths.forEach((p, i) => {
u.ctx.fillStyle = fillPalette[i];
u.ctx.fill(p);
});
u.ctx.restore();
}
);
};
}
// accepts xMax, yMin, yMax, count // accepts xMax, yMin, yMax, count
// xbinsize? x tile sizes are uniform? // xbinsize? x tile sizes are uniform?
export function heatmapPathsSparse(opts: PathbuilderOpts) { export function heatmapPathsSparse(opts: PathbuilderOpts) {