HeatmapNG: cell value filtering and color clamping (#50204)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Leon Sorokin
2022-06-06 02:21:47 -05:00
committed by GitHub
parent fac8db8ff6
commit 8cdfef4796
14 changed files with 265 additions and 151 deletions

View File

@@ -23,7 +23,7 @@
}, },
"editable": true, "editable": true,
"fiscalYearStartMonth": 0, "fiscalYearStartMonth": 0,
"graphTooltip": 0, "graphTooltip": 1,
"links": [], "links": [],
"liveNow": false, "liveNow": false,
"panels": [ "panels": [
@@ -172,7 +172,7 @@
"color": "rgba(255,0,255,0.7)" "color": "rgba(255,0,255,0.7)"
}, },
"filterValues": { "filterValues": {
"min": 1e-9 "le": 1e-9
}, },
"legend": { "legend": {
"show": true "show": true
@@ -257,7 +257,7 @@
"color": "rgba(255,0,255,0.7)" "color": "rgba(255,0,255,0.7)"
}, },
"filterValues": { "filterValues": {
"min": 1e-9 "le": 1e-9
}, },
"legend": { "legend": {
"show": true "show": true
@@ -333,7 +333,7 @@
"color": "rgba(255,0,255,0.7)" "color": "rgba(255,0,255,0.7)"
}, },
"filterValues": { "filterValues": {
"min": 1e-9 "le": 1e-9
}, },
"legend": { "legend": {
"show": true "show": true
@@ -417,7 +417,7 @@
"color": "rgba(255,0,255,0.7)" "color": "rgba(255,0,255,0.7)"
}, },
"filterValues": { "filterValues": {
"min": 1e-9 "le": 1e-9
}, },
"legend": { "legend": {
"show": true "show": true
@@ -502,7 +502,7 @@
"color": "rgba(255,0,255,0.7)" "color": "rgba(255,0,255,0.7)"
}, },
"filterValues": { "filterValues": {
"min": 1e-9 "le": 1e-9
}, },
"legend": { "legend": {
"show": true "show": true
@@ -548,6 +548,6 @@
"timezone": "", "timezone": "",
"title": "Heatmap calculate (log)", "title": "Heatmap calculate (log)",
"uid": "ZXYQTA97ZZ", "uid": "ZXYQTA97ZZ",
"version": 4, "version": 1,
"weekStart": "" "weekStart": ""
} }

View File

@@ -26,7 +26,7 @@ const GRADIENT_STOPS = 10;
export const ColorScale = ({ colorPalette, min, max, display, hoverValue, useStopsPercentage }: Props) => { export const ColorScale = ({ colorPalette, min, max, display, hoverValue, useStopsPercentage }: Props) => {
const [colors, setColors] = useState<string[]>([]); const [colors, setColors] = useState<string[]>([]);
const [scaleHover, setScaleHover] = useState<HoverState>({ isShown: false, value: 0 }); const [scaleHover, setScaleHover] = useState<HoverState>({ isShown: false, value: 0 });
const [percent, setPercent] = useState<number | null>(null); const [percent, setPercent] = useState<number | null>(null); // 0-100 for CSS percentage
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, colors); const styles = getStyles(theme, colors);
@@ -50,15 +50,12 @@ export const ColorScale = ({ colorPalette, min, max, display, hoverValue, useSto
}; };
useEffect(() => { useEffect(() => {
if (hoverValue != null) { setPercent(hoverValue == null ? null : clampPercent100((hoverValue - min) / (max - min)));
const percent = hoverValue / (max - min);
setPercent(percent * 100);
}
}, [hoverValue, min, max]); }, [hoverValue, min, max]);
return ( return (
<div className={styles.scaleWrapper}> <div className={styles.scaleWrapper} onMouseMove={onScaleMouseMove} onMouseLeave={onScaleMouseLeave}>
<div className={styles.scaleGradient} onMouseMove={onScaleMouseMove} onMouseLeave={onScaleMouseLeave}> <div className={styles.scaleGradient}>
{display && (scaleHover.isShown || hoverValue !== undefined) && ( {display && (scaleHover.isShown || hoverValue !== undefined) && (
<div className={styles.followerContainer}> <div className={styles.followerContainer}>
<div className={styles.follower} style={{ left: `${percent}%` }} /> <div className={styles.follower} style={{ left: `${percent}%` }} />
@@ -121,10 +118,19 @@ const getGradientStops = ({
return [...gradientStops]; return [...gradientStops];
}; };
function clampPercent100(v: number) {
if (v > 1) {
return 100;
}
if (v < 0) {
return 0;
}
return v * 100;
}
const getStyles = (theme: GrafanaTheme2, colors: string[]) => ({ const getStyles = (theme: GrafanaTheme2, colors: string[]) => ({
scaleWrapper: css` scaleWrapper: css`
width: 100%; width: 100%;
max-width: 300px;
font-size: 11px; font-size: 11px;
opacity: 1; opacity: 1;
`, `,
@@ -138,7 +144,7 @@ const getStyles = (theme: GrafanaTheme2, colors: string[]) => ({
`, `,
hoverValue: css` hoverValue: css`
position: absolute; position: absolute;
padding-top: 5px; padding-top: 4px;
`, `,
followerContainer: css` followerContainer: css`
position: relative; position: relative;

View File

@@ -24,11 +24,6 @@ const logModeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
value: HeatmapCalculationMode.Size, value: HeatmapCalculationMode.Size,
description: 'Split the buckets based on size', description: 'Split the buckets based on size',
}, },
{
label: 'Count',
value: HeatmapCalculationMode.Count,
description: 'Split the buckets based on count',
},
]; ];
export const AxisEditor: React.FC<StandardEditorProps<HeatmapCalculationBucketConfig, any>> = ({ export const AxisEditor: React.FC<StandardEditorProps<HeatmapCalculationBucketConfig, any>> = ({

View File

@@ -58,7 +58,7 @@ describe('Heatmap transformer', () => {
], ],
}); });
const heatmap = bucketsToScanlines({ frame, name: 'Speed' }); const heatmap = bucketsToScanlines({ frame, value: 'Speed' });
expect(heatmap.fields.map((f) => ({ name: f.name, type: f.type, config: f.config }))).toMatchInlineSnapshot(` expect(heatmap.fields.map((f) => ({ name: f.name, type: f.type, config: f.config }))).toMatchInlineSnapshot(`
Array [ Array [
Object { Object {

View File

@@ -63,7 +63,7 @@ export function readHeatmapScanlinesCustomMeta(frame?: DataFrame): HeatmapScanli
export interface BucketsOptions { export interface BucketsOptions {
frame: DataFrame; frame: DataFrame;
name?: string; value?: string; // the field value name
layout?: HeatmapBucketLayout; layout?: HeatmapBucketLayout;
} }
@@ -147,7 +147,7 @@ export function bucketsToScanlines(opts: BucketsOptions): DataFrame {
}, },
}, },
{ {
name: opts.name?.length ? opts.name : 'Value', name: opts.value?.length ? opts.value : 'Value',
type: FieldType.number, type: FieldType.number,
values: new ArrayVector(counts2), values: new ArrayVector(counts2),
config: yFields[0].config, config: yFields[0].config,

View File

@@ -57,7 +57,7 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
let exemplarsyFacet: number[] = []; let exemplarsyFacet: number[] = [];
const meta = readHeatmapScanlinesCustomMeta(info.heatmap); const meta = readHeatmapScanlinesCustomMeta(info.heatmap);
if (info.exemplars && meta.yMatchWithLabel) { if (info.exemplars?.length && meta.yMatchWithLabel) {
exemplarsXFacet = info.exemplars?.fields[0].values.toArray(); exemplarsXFacet = info.exemplars?.fields[0].values.toArray();
// ordinal/labeled heatmap-buckets? // ordinal/labeled heatmap-buckets?
@@ -126,7 +126,10 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
getTimeRange: () => timeRangeRef.current, getTimeRange: () => timeRangeRef.current,
palette, palette,
cellGap: options.cellGap, cellGap: options.cellGap,
hideThreshold: options.filterValues?.min, // eventually a better range hideLE: options.filterValues?.le,
hideGE: options.filterValues?.ge,
valueMin: options.color.min,
valueMax: options.color.max,
exemplarColor: options.exemplars?.color ?? 'rgba(255,0,255,0.7)', exemplarColor: options.exemplars?.color ?? 'rgba(255,0,255,0.7)',
yAxisConfig: options.yAxis, yAxisConfig: options.yAxis,
ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1, ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1,
@@ -143,7 +146,17 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
let countFieldIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3; let countFieldIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3;
const countField = info.heatmap.fields[countFieldIdx]; const countField = info.heatmap.fields[countFieldIdx];
const { min, max } = reduceField({ field: countField, reducers: [ReducerID.min, ReducerID.max] }); // TODO -- better would be to get the range from the real color scale!
let { min, max } = options.color;
if (min == null || max == null) {
const calc = reduceField({ field: countField, reducers: [ReducerID.min, ReducerID.max] });
if (min == null) {
min = calc[ReducerID.min];
}
if (max == null) {
max = calc[ReducerID.max];
}
}
let hoverValue: number | undefined = undefined; let hoverValue: number | undefined = undefined;
// seriesIdx: 1 is heatmap layer; 2 is exemplar layer // seriesIdx: 1 is heatmap layer; 2 is exemplar layer
@@ -154,7 +167,7 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
return ( return (
<VizLayout.Legend placement="bottom" maxHeight="20%"> <VizLayout.Legend placement="bottom" maxHeight="20%">
<div className={styles.colorScaleWrapper}> <div className={styles.colorScaleWrapper}>
<ColorScale hoverValue={hoverValue} colorPalette={palette} min={min} max={max} display={info.display} /> <ColorScale hoverValue={hoverValue} colorPalette={palette} min={min!} max={max!} display={info.display} />
</div> </div>
</VizLayout.Legend> </VizLayout.Legend>
); );
@@ -209,5 +222,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
colorScaleWrapper: css` colorScaleWrapper: css`
margin-left: 25px; margin-left: 25px;
padding: 10px 0; padding: 10px 0;
max-width: 300px;
`, `,
}); });

View File

@@ -75,7 +75,7 @@ export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme
} }
} }
return getHeatmapData(bucketsToScanlines({ ...options.bucket, frame: bucketHeatmap }), exemplars, theme); return getHeatmapData(bucketsToScanlines({ ...options.bucketFrame, frame: bucketHeatmap }), exemplars, theme);
} }
const getSparseHeatmapData = ( const getSparseHeatmapData = (
@@ -139,7 +139,7 @@ const getHeatmapData = (frame: DataFrame, exemplars: DataFrame | undefined, them
const data: HeatmapData = { const data: HeatmapData = {
heatmap: frame, heatmap: frame,
exemplars, exemplars: exemplars?.length ? exemplars : undefined,
xBucketSize: xBinIncr, xBucketSize: xBinIncr,
yBucketSize: yBinIncr, yBucketSize: yBinIncr,
xBucketCount: xBinQty, xBucketCount: xBinQty,

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -21,11 +21,14 @@ describe('Heatmap Migrations', () => {
expect(panel).toMatchInlineSnapshot(` expect(panel).toMatchInlineSnapshot(`
Object { Object {
"fieldConfig": Object { "fieldConfig": Object {
"defaults": Object {}, "defaults": Object {
"decimals": 6,
"unit": "short",
},
"overrides": Array [], "overrides": Array [],
}, },
"options": Object { "options": Object {
"bucket": Object { "bucketFrame": Object {
"layout": "auto", "layout": "auto",
}, },
"calculate": true, "calculate": true,
@@ -44,7 +47,7 @@ describe('Heatmap Migrations', () => {
}, },
}, },
"cellGap": 2, "cellGap": 2,
"cellSize": 10, "cellRadius": 10,
"color": Object { "color": Object {
"exponent": 0.5, "exponent": 0.5,
"fill": "dark-orange", "fill": "dark-orange",
@@ -59,7 +62,7 @@ describe('Heatmap Migrations', () => {
"color": "rgba(255,0,255,0.7)", "color": "rgba(255,0,255,0.7)",
}, },
"filterValues": Object { "filterValues": Object {
"min": 1e-9, "le": 1e-9,
}, },
"legend": Object { "legend": Object {
"show": true, "show": true,
@@ -72,6 +75,8 @@ describe('Heatmap Migrations', () => {
"yAxis": Object { "yAxis": Object {
"axisPlacement": "left", "axisPlacement": "left",
"axisWidth": 400, "axisWidth": 400,
"max": 22,
"min": 7,
"reverse": false, "reverse": false,
}, },
}, },
@@ -133,11 +138,11 @@ const oldHeatmap = {
yAxis: { yAxis: {
show: true, show: true,
format: 'short', format: 'short',
decimals: null, decimals: 6,
logBase: 2, logBase: 2,
splitFactor: 3, splitFactor: 3,
min: null, min: 7,
max: null, max: 22,
width: '400', width: '400',
}, },
xBucketSize: null, xBucketSize: null,

View File

@@ -60,6 +60,9 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS
}, },
}; };
} }
fieldConfig.defaults.unit = oldYAxis.format;
fieldConfig.defaults.decimals = oldYAxis.decimals;
} }
const options: PanelOptions = { const options: PanelOptions = {
@@ -69,14 +72,16 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS
...defaultPanelOptions.color, ...defaultPanelOptions.color,
steps: 128, // best match with existing colors steps: 128, // best match with existing colors
}, },
cellGap: asNumber(angular.cards?.cardPadding), cellGap: asNumber(angular.cards?.cardPadding, 2),
cellSize: asNumber(angular.cards?.cardRound), cellRadius: asNumber(angular.cards?.cardRound), // just to keep it
yAxis: { yAxis: {
axisPlacement: oldYAxis.show === false ? AxisPlacement.Hidden : AxisPlacement.Left, axisPlacement: oldYAxis.show === false ? AxisPlacement.Hidden : AxisPlacement.Left,
reverse: Boolean(angular.reverseYBuckets), reverse: Boolean(angular.reverseYBuckets),
axisWidth: oldYAxis.width ? +oldYAxis.width : undefined, axisWidth: oldYAxis.width ? +oldYAxis.width : undefined,
min: oldYAxis.min,
max: oldYAxis.max,
}, },
bucket: { bucketFrame: {
layout: getHeatmapBucketLayout(angular.yBucketBound), layout: getHeatmapBucketLayout(angular.yBucketBound),
}, },
legend: { legend: {
@@ -134,9 +139,12 @@ function getHeatmapBucketLayout(v?: string): HeatmapBucketLayout {
return HeatmapBucketLayout.auto; return HeatmapBucketLayout.auto;
} }
function asNumber(v: any): number | undefined { function asNumber(v: any, defaultValue?: number): number | undefined {
if (v == null || v === '') {
return defaultValue;
}
const num = +v; const num = +v;
return isNaN(num) ? undefined : num; return isNaN(num) ? defaultValue : num;
} }
export const heatmapMigrationHandler = (panel: PanelModel): Partial<PanelOptions> => { export const heatmapMigrationHandler = (panel: PanelModel): Partial<PanelOptions> => {

View File

@@ -34,11 +34,14 @@ export interface YAxisConfig extends AxisConfig {
unit?: string; unit?: string;
reverse?: boolean; reverse?: boolean;
decimals?: number; decimals?: number;
// Only used when the axis is not ordinal
min?: number;
max?: number;
} }
export interface FilterValueRange { export interface FilterValueRange {
min?: number; le?: number;
max?: number; ge?: number;
} }
export interface HeatmapTooltip { export interface HeatmapTooltip {
@@ -53,8 +56,8 @@ export interface ExemplarConfig {
color: string; color: string;
} }
export interface BucketOptions { export interface BucketFrameOptions {
name?: string; value?: string; // value field name
layout?: HeatmapBucketLayout; layout?: HeatmapBucketLayout;
} }
@@ -64,11 +67,11 @@ export interface PanelOptions {
color: HeatmapColorOptions; color: HeatmapColorOptions;
filterValues?: FilterValueRange; // was hideZeroBuckets filterValues?: FilterValueRange; // was hideZeroBuckets
bucket?: BucketOptions; bucketFrame?: BucketFrameOptions;
showValue: VisibilityMode; showValue: VisibilityMode;
cellGap?: number; // was cardPadding cellGap?: number; // was cardPadding
cellSize?: number; // was cardRadius cellRadius?: number; // was cardRadius (not used, but migrated from angular)
yAxis: YAxisConfig; yAxis: YAxisConfig;
legend: HeatmapLegend; legend: HeatmapLegend;
@@ -87,7 +90,7 @@ export const defaultPanelOptions: PanelOptions = {
exponent: 0.5, exponent: 0.5,
steps: 64, steps: 64,
}, },
bucket: { bucketFrame: {
layout: HeatmapBucketLayout.auto, layout: HeatmapBucketLayout.auto,
}, },
yAxis: { yAxis: {
@@ -105,7 +108,7 @@ export const defaultPanelOptions: PanelOptions = {
color: 'rgba(255,0,255,0.7)', color: 'rgba(255,0,255,0.7)',
}, },
filterValues: { filterValues: {
min: 1e-9, le: 1e-9,
}, },
cellGap: 1, cellGap: 1,
}; };

View File

@@ -63,33 +63,10 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
if (opts.calculate) { if (opts.calculate) {
addHeatmapCalculationOptions('calculation.', builder, opts.calculation, category); addHeatmapCalculationOptions('calculation.', builder, opts.calculation, category);
} else {
builder.addTextInput({
path: 'bucket.name',
name: 'Cell value name',
defaultValue: defaultPanelOptions.bucket?.name,
settings: {
placeholder: 'Value',
},
category,
});
builder.addRadio({
path: 'bucket.layout',
name: 'Layout',
defaultValue: defaultPanelOptions.bucket?.layout ?? HeatmapBucketLayout.auto,
category,
settings: {
options: [
{ label: 'Auto', value: HeatmapBucketLayout.auto },
{ label: 'Middle', value: HeatmapBucketLayout.unknown },
{ label: 'Lower (LE)', value: HeatmapBucketLayout.le },
{ label: 'Upper (GE)', value: HeatmapBucketLayout.ge },
],
},
});
} }
category = ['Y Axis']; category = ['Y Axis'];
builder.addRadio({ builder.addRadio({
path: 'yAxis.axisPlacement', path: 'yAxis.axisPlacement',
name: 'Placement', name: 'Placement',
@@ -104,6 +81,27 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
}, },
}); });
// TODO: support clamping the min/max range when there is a real axis
if (false && opts.calculate) {
builder
.addNumberInput({
path: 'yAxis.min',
name: 'Min value',
settings: {
placeholder: 'Auto',
},
category,
})
.addTextInput({
path: 'yAxis.max',
name: 'Max value',
settings: {
placeholder: 'Auto',
},
category,
});
}
builder builder
.addNumberInput({ .addNumberInput({
path: 'yAxis.axisWidth', path: 'yAxis.axisWidth',
@@ -123,14 +121,31 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
placeholder: 'Auto', placeholder: 'Auto',
}, },
category, category,
})
.addBooleanSwitch({
path: 'yAxis.reverse',
name: 'Reverse',
defaultValue: defaultPanelOptions.yAxis.reverse === true,
category,
}); });
if (!opts.calculate) {
builder.addRadio({
path: 'bucketFrame.layout',
name: 'Tick alignment',
defaultValue: defaultPanelOptions.bucketFrame?.layout ?? HeatmapBucketLayout.auto,
category,
settings: {
options: [
{ label: 'Auto', value: HeatmapBucketLayout.auto },
{ label: 'Top (LE)', value: HeatmapBucketLayout.le },
{ label: 'Middle', value: HeatmapBucketLayout.unknown },
{ label: 'Bottom (GE)', value: HeatmapBucketLayout.ge },
],
},
});
}
builder.addBooleanSwitch({
path: 'yAxis.reverse',
name: 'Reverse',
defaultValue: defaultPanelOptions.yAxis.reverse === true,
category,
});
category = ['Colors']; category = ['Colors'];
builder.addRadio({ builder.addRadio({
@@ -225,6 +240,26 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
}, },
}); });
builder
.addNumberInput({
path: 'color.min',
name: 'Start color scale from value',
defaultValue: defaultPanelOptions.color.min,
settings: {
placeholder: 'Auto (min)',
},
category,
})
.addNumberInput({
path: 'color.max',
name: 'End color scale at value',
defaultValue: defaultPanelOptions.color.max,
settings: {
placeholder: 'Auto (max)',
},
category,
});
category = ['Display']; category = ['Display'];
builder builder
@@ -241,12 +276,6 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
// ], // ],
// }, // },
// }) // })
.addNumberInput({
path: 'filterValues.min',
name: 'Hide cell counts <=',
defaultValue: defaultPanelOptions.filterValues?.min,
category,
})
.addSliderInput({ .addSliderInput({
name: 'Cell gap', name: 'Cell gap',
path: 'cellGap', path: 'cellGap',
@@ -256,6 +285,24 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
min: 0, min: 0,
max: 25, max: 25,
}, },
})
.addNumberInput({
path: 'filterValues.le',
name: 'Hide cells with values <=',
defaultValue: defaultPanelOptions.filterValues?.le,
settings: {
placeholder: 'None',
},
category,
})
.addNumberInput({
path: 'filterValues.ge',
name: 'Hide cells with values >=',
defaultValue: defaultPanelOptions.filterValues?.ge,
settings: {
placeholder: 'None',
},
category,
}); });
// .addSliderInput({ // .addSliderInput({
// name: 'Cell radius', // name: 'Cell radius',
@@ -277,6 +324,18 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
category, category,
}); });
if (!opts.calculate) {
builder.addTextInput({
path: 'bucketFrame.value',
name: 'Cell value name',
defaultValue: defaultPanelOptions.bucketFrame?.value,
settings: {
placeholder: 'Value',
},
category,
});
}
builder.addBooleanSwitch({ builder.addBooleanSwitch({
path: 'tooltip.yHistogram', path: 'tooltip.yHistogram',
name: 'Show histogram (Y axis)', name: 'Show histogram (Y axis)',

View File

@@ -5,14 +5,14 @@
"state": "alpha", "state": "alpha",
"info": { "info": {
"description": "Next generation heatmap visualization", "description": "Like a histogram over time",
"author": { "author": {
"name": "Grafana Labs", "name": "Grafana Labs",
"url": "https://grafana.com" "url": "https://grafana.com"
}, },
"logos": { "logos": {
"small": "img/heatmap.svg", "small": "img/icn-heatmap-panel.svg",
"large": "img/heatmap.svg" "large": "img/icn-heatmap-panel.svg"
} }
} }
} }

View File

@@ -1,7 +1,15 @@
import { MutableRefObject, RefObject } from 'react'; import { MutableRefObject, RefObject } from 'react';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { DataFrameType, GrafanaTheme2, incrRoundDn, incrRoundUp, TimeRange } from '@grafana/data'; import {
DataFrameType,
formattedValueToString,
getValueFormat,
GrafanaTheme2,
incrRoundDn,
incrRoundUp,
TimeRange,
} from '@grafana/data';
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '@grafana/schema'; import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '@grafana/schema';
import { UPlotConfigBuilder } from '@grafana/ui'; import { UPlotConfigBuilder } from '@grafana/ui';
import { readHeatmapScanlinesCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; import { readHeatmapScanlinesCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
@@ -15,7 +23,8 @@ import { PanelFieldConfig, YAxisConfig } from './models.gen';
interface PathbuilderOpts { 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; hideLE?: number;
hideGE?: number;
xAlign?: -1 | 0 | 1; xAlign?: -1 | 0 | 1;
yAlign?: -1 | 0 | 1; yAlign?: -1 | 0 | 1;
ySizeDivisor?: number; ySizeDivisor?: number;
@@ -55,7 +64,10 @@ interface PrepConfigOpts {
palette: string[]; palette: string[];
exemplarColor: string; exemplarColor: string;
cellGap?: number | null; // in css pixels cellGap?: number | null; // in css pixels
hideThreshold?: number; hideLE?: number;
hideGE?: number;
valueMin?: number;
valueMax?: number;
yAxisConfig: YAxisConfig; yAxisConfig: YAxisConfig;
ySizeDivisor?: number; ySizeDivisor?: number;
} }
@@ -72,7 +84,10 @@ export function prepConfig(opts: PrepConfigOpts) {
getTimeRange, getTimeRange,
palette, palette,
cellGap, cellGap,
hideThreshold, hideLE,
hideGE,
valueMin,
valueMax,
yAxisConfig, yAxisConfig,
ySizeDivisor, ySizeDivisor,
} = opts; } = opts;
@@ -289,12 +304,12 @@ export function prepConfig(opts: PrepConfigOpts) {
// how to expand scale range if inferred non-regular or log buckets? // how to expand scale range if inferred non-regular or log buckets?
} }
} }
return [dataMin, dataMax]; return [dataMin, dataMax];
}, },
}); });
const hasLabeledY = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap).yOrdinalDisplay != null; const isOrdianalY = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap).yOrdinalDisplay != null;
const disp = dataRef.current?.heatmap?.fields[1].display ?? getValueFormat('short');
builder.addAxis({ builder.addAxis({
scaleKey: 'y', scaleKey: 'y',
@@ -303,35 +318,51 @@ export function prepConfig(opts: PrepConfigOpts) {
size: yAxisConfig.axisWidth || null, size: yAxisConfig.axisWidth || null,
label: yAxisConfig.axisLabel, label: yAxisConfig.axisLabel,
theme: theme, theme: theme,
splits: hasLabeledY formatValue: (v: any) => formattedValueToString(disp(v)),
? () => { splits: isOrdianalY
const ys = dataRef.current?.heatmap?.fields[1].values.toArray()!; ? (self: uPlot) => {
const splits = ys.slice(0, ys.length - ys.lastIndexOf(ys[0])); const meta = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap);
if (!meta.yOrdinalDisplay) {
return [0, 1]; //?
}
let splits = meta.yOrdinalDisplay.map((v, idx) => idx);
const bucketSize = dataRef.current?.yBucketSize!; switch (dataRef.current?.yLayout) {
case HeatmapBucketLayout.le:
if (dataRef.current?.yLayout === HeatmapBucketLayout.le) { splits.unshift(-1);
splits.unshift(ys[0] - bucketSize); break;
} else { case HeatmapBucketLayout.ge:
splits.push(ys[ys.length - 1] + bucketSize); splits.push(splits.length);
break;
} }
// Skip labels when the height is too small
if (self.height < 60) {
splits = [splits[0], splits[splits.length - 1]];
} else {
while (splits.length > 3 && (self.height - 15) / splits.length < 10) {
splits = splits.filter((v, idx) => idx % 2 === 0); // remove half the items
}
}
return splits; return splits;
} }
: undefined, : undefined,
values: hasLabeledY values: isOrdianalY
? () => { ? (self: uPlot, splits) => {
const meta = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap); const meta = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap);
const yAxisValues = meta.yOrdinalDisplay?.slice()!; if (meta.yOrdinalDisplay) {
const isFromBuckets = meta.yOrdinalDisplay?.length && !('le' === meta.yMatchWithLabel); return splits.map((v) => {
const txt = meta.yOrdinalDisplay[v];
if (dataRef.current?.yLayout === HeatmapBucketLayout.le) { if (!txt && v < 0) {
yAxisValues.unshift(isFromBuckets ? '' : '0.0'); // assumes dense layout where lowest bucket's low bound is 0-ish // Check prometheus style labels
} else if (dataRef.current?.yLayout === HeatmapBucketLayout.ge) { if ('le' === meta.yMatchWithLabel) {
yAxisValues.push(isFromBuckets ? '' : '+Inf'); return '0.0';
}
}
return txt;
});
} }
return splits.map((v) => `${v}`);
return yAxisValues;
} }
: undefined, : undefined,
}); });
@@ -363,7 +394,8 @@ export function prepConfig(opts: PrepConfigOpts) {
}); });
}, },
gap: cellGap, gap: cellGap,
hideThreshold, hideLE,
hideGE,
xAlign: xAlign:
dataRef.current?.xLayout === HeatmapBucketLayout.le dataRef.current?.xLayout === HeatmapBucketLayout.le
? -1 ? -1
@@ -380,7 +412,7 @@ export function prepConfig(opts: PrepConfigOpts) {
fill: { fill: {
values: (u, seriesIdx) => { values: (u, seriesIdx) => {
let countFacetIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3; let countFacetIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3;
return countsToFills(u.data[seriesIdx][countFacetIdx] as unknown as number[], palette); return valuesToFills(u.data[seriesIdx][countFacetIdx] as unknown as number[], palette, valueMin, valueMax);
}, },
index: palette, index: palette,
}, },
@@ -465,7 +497,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, xAlign = 1, yAlign = 1, ySizeDivisor = 1 } = opts; const { disp, each, gap = 1, hideLE = -Infinity, hideGE = Infinity, xAlign = 1, yAlign = 1, ySizeDivisor = 1 } = opts;
const pxRatio = devicePixelRatio; const pxRatio = devicePixelRatio;
@@ -549,14 +581,7 @@ export function heatmapPathsDense(opts: PathbuilderOpts) {
); );
for (let i = 0; i < dlen; i++) { for (let i = 0; i < dlen; i++) {
// filter out 0 counts and out of view if (counts[i] > hideLE && counts[i] < hideGE) {
if (
counts[i] > hideThreshold &&
xs[i] + xBinIncr >= scaleX.min! &&
xs[i] - xBinIncr <= scaleX.max! &&
ys[i] + yBinIncr >= scaleY.min! &&
ys[i] - yBinIncr <= scaleY.max!
) {
let cx = cxs[~~(i / yBinQty)]; let cx = cxs[~~(i / yBinQty)];
let cy = cys[i % yBinQty]; let cy = cys[i % yBinQty];
@@ -646,7 +671,7 @@ export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: strin
// 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) {
const { disp, each, gap = 1, hideThreshold = 0 } = opts; const { disp, each, gap = 1, hideLE = -Infinity, hideGE = Infinity } = opts;
const pxRatio = devicePixelRatio; const pxRatio = devicePixelRatio;
@@ -717,7 +742,7 @@ export function heatmapPathsSparse(opts: PathbuilderOpts) {
let xSizeUniform = xOffs.get(xMaxs.find((v) => v !== xMaxs[0])) - xOffs.get(xMaxs[0]); let xSizeUniform = xOffs.get(xMaxs.find((v) => v !== xMaxs[0])) - xOffs.get(xMaxs[0]);
for (let i = 0; i < dlen; i++) { for (let i = 0; i < dlen; i++) {
if (counts[i] <= hideThreshold) { if (counts[i] <= hideLE || counts[i] >= hideGE) {
continue; continue;
} }
@@ -739,19 +764,11 @@ export function heatmapPathsSparse(opts: PathbuilderOpts) {
let x = xMaxPx; let x = xMaxPx;
let y = yMinPx; let y = yMinPx;
// filter out 0 counts and out of view
// if (
// xs[i] + xBinIncr >= scaleX.min! &&
// xs[i] - xBinIncr <= scaleX.max! &&
// ys[i] + yBinIncr >= scaleY.min! &&
// ys[i] - yBinIncr <= scaleY.max!
// ) {
let fillPath = fillPaths[fills[i]]; let fillPath = fillPaths[fills[i]];
rect(fillPath, x, y, xSize, ySize); rect(fillPath, x, y, xSize, ySize);
each(u, 1, i, x, y, xSize, ySize); each(u, 1, i, x, y, xSize, ySize);
// }
} }
u.ctx.save(); u.ctx.save();
@@ -772,29 +789,36 @@ export function heatmapPathsSparse(opts: PathbuilderOpts) {
}; };
} }
export const countsToFills = (counts: number[], palette: string[]) => { export const valuesToFills = (values: number[], palette: string[], minValue?: number, maxValue?: number) => {
// TODO: integrate 1e-9 hideThreshold? if (minValue == null) {
const hideThreshold = 0; minValue = Infinity;
let minCount = Infinity; for (let i = 0; i < values.length; i++) {
let maxCount = -Infinity; minValue = Math.min(minValue, values[i]);
for (let i = 0; i < counts.length; i++) {
if (counts[i] > hideThreshold) {
minCount = Math.min(minCount, counts[i]);
maxCount = Math.max(maxCount, counts[i]);
} }
} }
let range = maxCount - minCount; if (maxValue == null) {
maxValue = -Infinity;
for (let i = 0; i < values.length; i++) {
maxValue = Math.max(maxValue, values[i]);
}
}
let range = maxValue - minValue;
let paletteSize = palette.length; let paletteSize = palette.length;
let indexedFills = Array(counts.length); let indexedFills = Array(values.length);
for (let i = 0; i < counts.length; i++) { for (let i = 0; i < values.length; i++) {
indexedFills[i] = indexedFills[i] =
counts[i] === 0 ? -1 : Math.min(paletteSize - 1, Math.floor((paletteSize * (counts[i] - minCount)) / range)); values[i] < minValue
? 0
: values[i] > maxValue
? paletteSize - 1
: Math.min(paletteSize - 1, Math.floor((paletteSize * (values[i] - minValue)) / range));
} }
return indexedFills; return indexedFills;