HeatmapNG: add log scale calculation (#49969)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Ryan McKinley 2022-06-03 19:02:44 -07:00 committed by GitHub
parent 219e848e73
commit fd34700225
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1206 additions and 252 deletions

View File

@ -0,0 +1,554 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "points",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 2,
"scaleDistribution": {
"log": 2,
"type": "log"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 14,
"w": 8,
"x": 0,
"y": 0
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "9.0.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"max": 500000,
"min": 0.01,
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 2,
"spread": 1000,
"startValue": 0.01
}
],
"title": "Time series",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"fieldConfig": {
"defaults": {
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"scaleDistribution": {
"type": "linear"
}
}
},
"overrides": []
},
"gridPos": {
"h": 14,
"w": 8,
"x": 8,
"y": 0
},
"id": 6,
"options": {
"bucket": {
"layout": "auto"
},
"calculate": true,
"calculation": {
"yBuckets": {
"scale": {
"log": 2,
"type": "log"
}
}
},
"cellGap": 1,
"color": {
"exponent": 0.5,
"fill": "dark-orange",
"mode": "scheme",
"scale": "exponential",
"scheme": "Spectral",
"steps": 64
},
"exemplars": {
"color": "rgba(255,0,255,0.7)"
},
"filterValues": {
"min": 1e-9
},
"legend": {
"show": true
},
"mode": "calculate",
"tooltip": {
"show": true,
"yHistogram": false
},
"yAxis": {
"axisPlacement": "left",
"reverse": false
},
"yAxisLabels": "auto",
"yAxisReverse": false
},
"pluginVersion": "9.0.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "A"
}
],
"title": "log2",
"type": "heatmap-new"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"fieldConfig": {
"defaults": {
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"scaleDistribution": {
"type": "linear"
}
}
},
"overrides": []
},
"gridPos": {
"h": 14,
"w": 8,
"x": 16,
"y": 0
},
"id": 7,
"options": {
"bucket": {
"layout": "auto"
},
"calculate": true,
"calculation": {
"yBuckets": {
"scale": {
"log": 2,
"type": "log"
},
"value": "2"
}
},
"cellGap": 1,
"color": {
"exponent": 0.5,
"fill": "dark-orange",
"mode": "scheme",
"scale": "exponential",
"scheme": "Spectral",
"steps": 64
},
"exemplars": {
"color": "rgba(255,0,255,0.7)"
},
"filterValues": {
"min": 1e-9
},
"legend": {
"show": true
},
"mode": "calculate",
"tooltip": {
"show": true,
"yHistogram": false
},
"yAxis": {
"axisPlacement": "left",
"reverse": false
},
"yAxisLabels": "auto",
"yAxisReverse": false
},
"pluginVersion": "9.0.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "A"
}
],
"title": "log2 split 2",
"type": "heatmap-new"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"fieldConfig": {
"defaults": {
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"scaleDistribution": {
"type": "linear"
}
}
},
"overrides": []
},
"gridPos": {
"h": 14,
"w": 8,
"x": 0,
"y": 14
},
"id": 4,
"options": {
"bucket": {
"layout": "auto"
},
"calculate": true,
"cellGap": 1,
"color": {
"exponent": 0.5,
"fill": "dark-orange",
"mode": "scheme",
"scale": "exponential",
"scheme": "Spectral",
"steps": 64
},
"exemplars": {
"color": "rgba(255,0,255,0.7)"
},
"filterValues": {
"min": 1e-9
},
"legend": {
"show": true
},
"mode": "calculate",
"tooltip": {
"show": true,
"yHistogram": false
},
"yAxis": {
"axisPlacement": "left",
"reverse": false
},
"yAxisLabels": "auto",
"yAxisReverse": false
},
"pluginVersion": "9.0.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "A"
}
],
"title": "linear",
"type": "heatmap-new"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"fieldConfig": {
"defaults": {
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"scaleDistribution": {
"type": "linear"
}
}
},
"overrides": []
},
"gridPos": {
"h": 14,
"w": 8,
"x": 8,
"y": 14
},
"id": 5,
"options": {
"bucket": {
"layout": "auto"
},
"calculate": true,
"calculation": {
"yBuckets": {
"scale": {
"log": 10,
"type": "log"
}
}
},
"cellGap": 1,
"color": {
"exponent": 0.5,
"fill": "dark-orange",
"mode": "scheme",
"scale": "exponential",
"scheme": "Spectral",
"steps": 64
},
"exemplars": {
"color": "rgba(255,0,255,0.7)"
},
"filterValues": {
"min": 1e-9
},
"legend": {
"show": true
},
"mode": "calculate",
"tooltip": {
"show": true,
"yHistogram": false
},
"yAxis": {
"axisPlacement": "left",
"reverse": false
},
"yAxisLabels": "auto",
"yAxisReverse": false
},
"pluginVersion": "9.0.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "A"
}
],
"title": "log10",
"type": "heatmap-new"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"fieldConfig": {
"defaults": {
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"scaleDistribution": {
"type": "linear"
}
}
},
"overrides": []
},
"gridPos": {
"h": 14,
"w": 8,
"x": 16,
"y": 14
},
"id": 8,
"options": {
"bucket": {
"layout": "auto"
},
"calculate": true,
"calculation": {
"yBuckets": {
"scale": {
"log": 10,
"type": "log"
},
"value": "2"
}
},
"cellGap": 1,
"color": {
"exponent": 0.5,
"fill": "dark-orange",
"mode": "scheme",
"scale": "exponential",
"scheme": "Spectral",
"steps": 64
},
"exemplars": {
"color": "rgba(255,0,255,0.7)"
},
"filterValues": {
"min": 1e-9
},
"legend": {
"show": true
},
"mode": "calculate",
"tooltip": {
"show": true,
"yHistogram": false
},
"yAxis": {
"axisPlacement": "left",
"reverse": false
},
"yAxisLabels": "auto",
"yAxisReverse": false
},
"pluginVersion": "9.0.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "A"
}
],
"title": "log10 split 2",
"type": "heatmap-new"
}
],
"schemaVersion": 36,
"style": "dark",
"tags": ["gdev", "panel-tests", "graph-ng"],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Heatmap calculate (log)",
"uid": "ZXYQTA97ZZ",
"version": 4,
"weekStart": ""
}

View File

@ -2,10 +2,10 @@ import React from 'react';
import {
FieldConfigEditorBuilder,
FieldOverrideEditorProps,
FieldType,
identityOverrideProcessor,
SelectableValue,
StandardEditorProps,
} from '@grafana/data';
import { AxisConfig, AxisPlacement, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema';
@ -89,8 +89,8 @@ export function addAxisConfig(
path: 'scaleDistribution',
name: 'Scale',
category,
editor: ScaleDistributionEditor,
override: ScaleDistributionEditor,
editor: ScaleDistributionEditor as any,
override: ScaleDistributionEditor as any,
defaultValue: { type: ScaleDistribution.Linear },
shouldApply: (f) => f.type === FieldType.number,
process: identityOverrideProcessor,
@ -121,19 +121,16 @@ const LOG_DISTRIBUTION_OPTIONS: Array<SelectableValue<number>> = [
];
/**
* @alpha
* @internal
*/
const ScaleDistributionEditor: React.FC<FieldOverrideEditorProps<ScaleDistributionConfig, any>> = ({
value,
onChange,
}) => {
export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps<ScaleDistributionConfig>) => {
const type = value?.type ?? ScaleDistribution.Linear;
return (
<HorizontalGroup>
<RadioButtonGroup
value={value.type || ScaleDistribution.Linear}
value={type}
options={DISTRIBUTION_OPTIONS}
onChange={(v) => {
console.log(v, value);
onChange({
...value,
type: v!,
@ -141,9 +138,8 @@ const ScaleDistributionEditor: React.FC<FieldOverrideEditorProps<ScaleDistributi
});
}}
/>
{value.type === ScaleDistribution.Log && (
{type === ScaleDistribution.Log && (
<Select
allowCustomValue={false}
options={LOG_DISTRIBUTION_OPTIONS}
value={value.log || 2}
prefix={'base'}

View File

@ -25,7 +25,7 @@ const supplier = (
export const HeatmapTransformerEditor: React.FC<TransformerUIProps<HeatmapTransformerOptions>> = (props) => {
useEffect(() => {
if (!props.options.xAxis?.mode) {
if (!props.options.xBuckets?.mode) {
const opts = getDefaultOptions(supplier);
props.onChange({ ...opts, ...props.options });
console.log('geometry useEffect', opts);

View File

@ -1,9 +1,9 @@
import React from 'react';
import { SelectableValue, StandardEditorProps } from '@grafana/data';
import { HorizontalGroup, Input, RadioButtonGroup } from '@grafana/ui';
import { HorizontalGroup, Input, RadioButtonGroup, ScaleDistribution } from '@grafana/ui';
import { HeatmapCalculationAxisConfig, HeatmapCalculationMode } from '../models.gen';
import { HeatmapCalculationBucketConfig, HeatmapCalculationMode } from '../models.gen';
const modeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
{
@ -18,7 +18,20 @@ const modeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
},
];
export const AxisEditor: React.FC<StandardEditorProps<HeatmapCalculationAxisConfig, any>> = ({
const logModeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
{
label: 'Split',
value: HeatmapCalculationMode.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>> = ({
value,
onChange,
item,
@ -27,7 +40,7 @@ export const AxisEditor: React.FC<StandardEditorProps<HeatmapCalculationAxisConf
<HorizontalGroup>
<RadioButtonGroup
value={value?.mode || HeatmapCalculationMode.Size}
options={modeOptions}
options={value?.scale?.type === ScaleDistribution.Log ? logModeOptions : modeOptions}
onChange={(mode) => {
onChange({
...value,

View File

@ -1,4 +1,6 @@
import { PanelOptionsEditorBuilder } from '@grafana/data';
import { ScaleDistribution } from '@grafana/schema';
import { ScaleDistributionEditor } from '@grafana/ui/src/options/builder';
import { HeatmapCalculationMode, HeatmapCalculationOptions } from '../models.gen';
@ -11,9 +13,9 @@ export function addHeatmapCalculationOptions(
category?: string[]
) {
builder.addCustomEditor({
id: 'xAxis',
path: `${prefix}xAxis`,
name: 'X Buckets',
id: 'xBuckets',
path: `${prefix}xBuckets`,
name: 'X Bucket',
editor: AxisEditor,
category,
defaultValue: {
@ -22,13 +24,22 @@ export function addHeatmapCalculationOptions(
});
builder.addCustomEditor({
id: 'yAxis',
path: `${prefix}yAxis`,
name: 'Y Buckets',
id: 'yBuckets',
path: `${prefix}yBuckets`,
name: 'Y Bucket',
editor: AxisEditor,
category,
defaultValue: {
mode: HeatmapCalculationMode.Size,
},
});
builder.addCustomEditor({
id: 'yBuckets-scale',
path: `${prefix}yBuckets.scale`,
name: 'Y Bucket scale',
category,
editor: ScaleDistributionEditor,
defaultValue: { type: ScaleDistribution.Linear },
});
}

View File

@ -1,7 +1,7 @@
import { FieldType } from '@grafana/data';
import { toDataFrame } from '@grafana/data/src/dataframe/processDataFrame';
import { calculateHeatmapFromData } from './heatmap';
import { bucketsToScanlines, calculateHeatmapFromData } from './heatmap';
import { HeatmapCalculationOptions } from './models.gen';
describe('Heatmap transformer', () => {
@ -13,12 +13,100 @@ describe('Heatmap transformer', () => {
const data = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3, 4] },
{ name: 'temp', type: FieldType.number, values: [1.1, 2.2, 3.3, 4.4] },
{ name: 'temp', type: FieldType.number, config: { unit: 'm2' }, values: [1.1, 2.2, 3.3, 4.4] },
],
});
const heatmap = calculateHeatmapFromData([data], options);
expect(heatmap.fields.map((f) => ({ name: f.name, type: f.type, config: f.config }))).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "xMin",
"type": "time",
},
Object {
"config": Object {
"custom": Object {
"scaleDistribution": Object {
"type": "linear",
},
},
"unit": "m2",
},
"name": "yMin",
"type": "number",
},
Object {
"config": Object {
"unit": "short",
},
"name": "Count",
"type": "number",
},
]
`);
});
expect(heatmap).toBeDefined();
it('convert heatmap buckets to scanlines', async () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'A', type: FieldType.number, config: { unit: 'm2' }, values: [1.1, 1.2, 1.3] },
{ name: 'B', type: FieldType.number, config: { unit: 'm2' }, values: [2.1, 2.2, 2.3] },
{ name: 'C', type: FieldType.number, config: { unit: 'm2' }, values: [3.1, 3.2, 3.3] },
],
});
const heatmap = bucketsToScanlines({ frame, name: 'Speed' });
expect(heatmap.fields.map((f) => ({ name: f.name, type: f.type, config: f.config }))).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "xMax",
"type": "time",
},
Object {
"config": Object {
"unit": "short",
},
"name": "y",
"type": "number",
},
Object {
"config": Object {
"unit": "m2",
},
"name": "Speed",
"type": "number",
},
]
`);
expect(heatmap.meta).toMatchInlineSnapshot(`
Object {
"custom": Object {
"yMatchWithLabel": undefined,
"yOrdinalDisplay": Array [
"A",
"B",
"C",
],
},
"type": "heatmap-scanlines",
}
`);
expect(heatmap.fields[1].values.toArray()).toMatchInlineSnapshot(`
Array [
0,
1,
2,
0,
1,
2,
0,
1,
2,
]
`);
});
});

View File

@ -12,8 +12,9 @@ import {
getFieldDisplayName,
Field,
} from '@grafana/data';
import { ScaleDistribution } from '@grafana/schema';
import { HeatmapCalculationMode, HeatmapCalculationOptions } from './models.gen';
import { HeatmapBucketLayout, HeatmapCalculationMode, HeatmapCalculationOptions } from './models.gen';
import { niceLinearIncrs, niceTimeIncrs } from './utils';
export interface HeatmapTransformerOptions extends HeatmapCalculationOptions {
@ -48,21 +49,39 @@ export function sortAscStrInf(aName?: string | null, bName?: string | null) {
return parseNumeric(aName) - parseNumeric(bName);
}
export interface HeatmapScanlinesCustomMeta {
/** This provides the lookup values */
yOrdinalDisplay: string[];
yOrdinalLabel?: string[];
yMatchWithLabel?: string;
}
/** simple utility to get heatmap metadata from a frame */
export function readHeatmapScanlinesCustomMeta(frame?: DataFrame): HeatmapScanlinesCustomMeta {
return (frame?.meta?.custom ?? {}) as HeatmapScanlinesCustomMeta;
}
export interface BucketsOptions {
frame: DataFrame;
name?: string;
layout?: HeatmapBucketLayout;
}
/** Given existing buckets, create a values style frame */
// Assumes frames have already been sorted ASC and de-accumulated.
export function bucketsToScanlines(frame: DataFrame): DataFrame {
export function bucketsToScanlines(opts: BucketsOptions): DataFrame {
// TODO: handle null-filling w/ fields[0].config.interval?
const xField = frame.fields[0];
const xField = opts.frame.fields[0];
const xValues = xField.values.toArray();
const yField = frame.fields[1];
const yFields = opts.frame.fields.filter((f, idx) => f.type === FieldType.number && idx > 0);
// similar to initBins() below
const len = xValues.length * (frame.fields.length - 1);
const len = xValues.length * yFields.length;
const xs = new Array(len);
const ys = new Array(len);
const counts2 = new Array(len);
const counts = frame.fields.slice(1).map((field) => field.values.toArray().slice());
const counts = yFields.map((field) => field.values.toArray().slice());
// transpose
counts.forEach((bucketCounts, bi) => {
@ -71,7 +90,7 @@ export function bucketsToScanlines(frame: DataFrame): DataFrame {
}
});
const bucketBounds = Array.from({ length: frame.fields.length - 1 }, (v, i) => i);
const bucketBounds = Array.from({ length: yFields.length }, (v, i) => i);
// fill flat/repeating array
for (let i = 0, yi = 0, xi = 0; i < len; yi = ++i % bucketBounds.length) {
@ -84,10 +103,33 @@ export function bucketsToScanlines(frame: DataFrame): DataFrame {
xs[i] = xValues[xi];
}
// this name determines whether cells are drawn above, below, or centered on the values
let ordinalFieldName = yFields[0].labels?.le != null ? 'yMax' : 'y';
switch (opts.layout) {
case HeatmapBucketLayout.le:
ordinalFieldName = 'yMax';
break;
case HeatmapBucketLayout.ge:
ordinalFieldName = 'yMin';
break;
case HeatmapBucketLayout.unknown:
ordinalFieldName = 'y';
break;
}
const custom: HeatmapScanlinesCustomMeta = {
yOrdinalDisplay: yFields.map((f) => getFieldDisplayName(f, opts.frame)),
yMatchWithLabel: Object.keys(yFields[0].labels ?? {})[0],
};
if (custom.yMatchWithLabel) {
custom.yOrdinalLabel = yFields.map((f) => f.labels?.[custom.yMatchWithLabel!] ?? '');
}
return {
length: xs.length,
refId: opts.frame.refId,
meta: {
type: DataFrameType.HeatmapScanlines,
custom,
},
fields: [
{
@ -97,19 +139,19 @@ export function bucketsToScanlines(frame: DataFrame): DataFrame {
config: xField.config,
},
{
// this name determines whether cells are drawn above, below, or centered on the values
name: yField.labels?.le != null ? 'yMax' : 'y',
name: ordinalFieldName,
type: FieldType.number,
values: new ArrayVector(ys),
config: yField.config,
config: {
unit: 'short', // ordinal lookup
},
},
{
name: 'count',
name: opts.name?.length ? opts.name : 'Value',
type: FieldType.number,
values: new ArrayVector(counts2),
config: {
unit: 'short',
},
config: yFields[0].config,
display: yFields[0].display,
},
],
};
@ -195,13 +237,24 @@ export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCa
throw 'no values found';
}
const xBucketsCfg = options.xBuckets ?? {};
const yBucketsCfg = options.yBuckets ?? {};
if (xBucketsCfg.scale?.type === ScaleDistribution.Log) {
throw 'X axis only supports linear buckets';
}
const scaleDistribution = options.yBuckets?.scale ?? {
type: ScaleDistribution.Linear,
};
const heat2d = heatmap(xs, ys, {
xSorted: true,
xTime: xField.type === FieldType.time,
xMode: options.xAxis?.mode,
xSize: +(options.xAxis?.value ?? 0),
yMode: options.yAxis?.mode,
ySize: +(options.yAxis?.value ?? 0),
xMode: xBucketsCfg.mode,
xSize: xBucketsCfg.value ? +xBucketsCfg.value : undefined,
yMode: yBucketsCfg.mode,
ySize: yBucketsCfg.value ? +yBucketsCfg.value : undefined,
yLog: scaleDistribution?.type === ScaleDistribution.Log ? (scaleDistribution?.log as any) : undefined,
});
const frame = {
@ -221,10 +274,15 @@ export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCa
name: 'yMin',
type: FieldType.number,
values: new ArrayVector(heat2d.y),
config: yField.config, // keep units from the original source
config: {
...yField.config, // keep units from the original source
custom: {
scaleDistribution,
},
},
},
{
name: 'count',
name: 'Count',
type: FieldType.number,
values: new ArrayVector(heat2d.count),
config: {
@ -294,6 +352,12 @@ function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) {
}
}
let yExp = opts?.yLog;
if (yExp && (minY <= 0 || maxY <= 0)) {
throw 'Log Y axes cannot have values <= 0';
}
//let scaleX = opts?.xLog === 10 ? Math.log10 : opts?.xLog === 2 ? Math.log2 : (v: number) => v;
//let scaleY = opts?.yLog === 10 ? Math.log10 : opts?.yLog === 2 ? Math.log2 : (v: number) => v;
@ -338,6 +402,12 @@ function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) {
let binX = opts?.xCeil ? (v: number) => incrRoundUp(v, xBinIncr) : (v: number) => incrRoundDn(v, xBinIncr);
let binY = opts?.yCeil ? (v: number) => incrRoundUp(v, yBinIncr) : (v: number) => incrRoundDn(v, yBinIncr);
if (yExp) {
yBinIncr = 1 / (opts?.ySize ?? 1); // sub-divides log exponents
let yLog = yExp === 2 ? Math.log2 : Math.log10;
binY = opts?.yCeil ? (v: number) => incrRoundUp(yLog(v), yBinIncr) : (v: number) => incrRoundDn(yLog(v), yBinIncr);
}
let minXBin = binX(minX);
let maxXBin = binX(maxX);
let minYBin = binY(minY);
@ -346,7 +416,7 @@ function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) {
let xBinQty = Math.round((maxXBin - minXBin) / xBinIncr) + 1;
let yBinQty = Math.round((maxYBin - minYBin) / yBinIncr) + 1;
let [xs2, ys2, counts] = initBins(xBinQty, yBinQty, minXBin, xBinIncr, minYBin, yBinIncr);
let [xs2, ys2, counts] = initBins(xBinQty, yBinQty, minXBin, xBinIncr, minYBin, yBinIncr, yExp);
for (let i = 0; i < len; i++) {
const xi = (binX(xs[i]) - minXBin) / xBinIncr;
@ -363,7 +433,7 @@ function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) {
};
}
function initBins(xQty: number, yQty: number, xMin: number, xIncr: number, yMin: number, yIncr: number) {
function initBins(xQty: number, yQty: number, xMin: number, xIncr: number, yMin: number, yIncr: number, yExp?: number) {
const len = xQty * yQty;
const xs = new Array<number>(len);
const ys = new Array<number>(len);
@ -371,7 +441,12 @@ function initBins(xQty: number, yQty: number, xMin: number, xIncr: number, yMin:
for (let i = 0, yi = 0, x = xMin; i < len; yi = ++i % yQty) {
counts[i] = 0;
ys[i] = yMin + yi * yIncr;
if (yExp) {
ys[i] = yExp ** (yMin + yi * yIncr);
} else {
ys[i] = yMin + yi * yIncr;
}
if (yi === 0 && i >= yQty) {
x += xIncr;

View File

@ -1,18 +1,24 @@
import { DataFrameType } from '@grafana/data';
import { ScaleDistributionConfig } from '@grafana/schema';
export enum HeatmapCalculationMode {
Size = 'size',
Size = 'size', // When exponential, this is "splitFactor"
Count = 'count',
}
export interface HeatmapCalculationAxisConfig {
export const enum HeatmapBucketLayout {
le = 'le',
ge = 'ge',
unknown = 'unknown', // unknown
auto = 'auto', // becomes unknown
}
export interface HeatmapCalculationBucketConfig {
mode?: HeatmapCalculationMode;
value?: string; // number or interval string ie 10s
value?: string; // number or interval string ie 10s, or log "split" divisor
scale?: ScaleDistributionConfig;
}
export interface HeatmapCalculationOptions {
xAxis?: HeatmapCalculationAxisConfig;
yAxis?: HeatmapCalculationAxisConfig;
xAxisField?: string; // name of the x field
encoding?: DataFrameType.HeatmapBuckets | DataFrameType.HeatmapScanlines;
xBuckets?: HeatmapCalculationBucketConfig;
yBuckets?: HeatmapCalculationBucketConfig;
}

View File

@ -3,10 +3,12 @@ import React, { useEffect, useRef } from 'react';
import { DataFrameType, Field, FieldType, formattedValueToString, getFieldDisplayName, LinkModel } from '@grafana/data';
import { LinkButton, VerticalGroup } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { readHeatmapScanlinesCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { HeatmapBucketLayout } from 'app/features/transformers/calculateHeatmap/models.gen';
import { DataHoverView } from '../geomap/components/DataHoverView';
import { BucketLayout, HeatmapData } from './fields';
import { HeatmapData } from './fields';
import { HeatmapHoverEvent } from './utils';
type Props = {
@ -44,26 +46,15 @@ const HeatmapHoverCell = ({ data, hover, showHistogram }: Props) => {
const yVals = yField?.values.toArray();
const countVals = countField?.values.toArray();
let yDispSrc, yDisp;
// labeled buckets
if (data.yAxisValues) {
yDispSrc = data.yAxisValues;
yDisp = (v: any) => v;
} else {
yDispSrc = yVals;
yDisp = (v: any) => {
if (yField?.display) {
return formattedValueToString(yField.display(v));
}
return `${v}`;
};
}
const meta = readHeatmapScanlinesCustomMeta(data.heatmap);
const yDispSrc = meta.yOrdinalDisplay ?? yVals;
const yDisp = yField?.display ? (v: any) => formattedValueToString(yField.display!(v)) : (v: any) => `${v}`;
const yValueIdx = index % data.yBucketCount! ?? 0;
const yMinIdx = data.yLayout === BucketLayout.le ? yValueIdx - 1 : yValueIdx;
const yMaxIdx = data.yLayout === BucketLayout.le ? yValueIdx : yValueIdx + 1;
const yMinIdx = data.yLayout === HeatmapBucketLayout.le ? yValueIdx - 1 : yValueIdx;
const yMaxIdx = data.yLayout === HeatmapBucketLayout.le ? yValueIdx : yValueIdx + 1;
const yBucketMin = yDispSrc?.[yMinIdx];
const yBucketMax = yDispSrc?.[yMaxIdx];
@ -171,6 +162,18 @@ const HeatmapHoverCell = ({ data, hover, showHistogram }: Props) => {
);
}
const renderYBuckets = () => {
switch (data.yLayout) {
case HeatmapBucketLayout.unknown:
return <div>{yDisp(yBucketMin)}</div>;
}
return (
<div>
Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
</div>
);
};
return (
<>
<div>
@ -186,15 +189,9 @@ const HeatmapHoverCell = ({ data, hover, showHistogram }: Props) => {
/>
)}
<div>
{data.yLayout === BucketLayout.unknown ? (
<div>{yDisp(yBucketMin)}</div>
) : (
<div>
Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
</div>
)}
{renderYBuckets()}
<div>
{getFieldDisplayName(countField!, data.heatmap)}: {count}
{getFieldDisplayName(countField!, data.heatmap)}: {data.display!(count)}
</div>
</div>
{links.length > 0 && (

View File

@ -3,9 +3,19 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import { DataFrameType, GrafanaTheme2, PanelProps, reduceField, ReducerID, TimeRange } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { Portal, UPlotChart, useStyles2, useTheme2, VizLayout, VizTooltipContainer } from '@grafana/ui';
import { ScaleDistributionConfig } from '@grafana/schema';
import {
Portal,
ScaleDistribution,
UPlotChart,
useStyles2,
useTheme2,
VizLayout,
VizTooltipContainer,
} from '@grafana/ui';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
import { readHeatmapScanlinesCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { HeatmapHoverView } from './HeatmapHoverView';
import { prepareHeatmapData } from './fields';
@ -46,24 +56,25 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
let exemplarsXFacet: number[] = []; // "Time" field
let exemplarsyFacet: number[] = [];
if (info.exemplars && info.matchByLabel) {
const meta = readHeatmapScanlinesCustomMeta(info.heatmap);
if (info.exemplars && meta.yMatchWithLabel) {
exemplarsXFacet = info.exemplars?.fields[0].values.toArray();
// ordinal/labeled heatmap-buckets?
const hasLabeledY = info.yLabelValues != null;
const hasLabeledY = meta.yOrdinalDisplay != null;
if (hasLabeledY) {
let matchExemplarsBy = info.exemplars?.fields
.find((field) => field.name === info.matchByLabel)!
.find((field) => field.name === meta.yMatchWithLabel)!
.values.toArray();
exemplarsyFacet = matchExemplarsBy.map((label) => info.yLabelValues?.indexOf(label)) as number[];
exemplarsyFacet = matchExemplarsBy.map((label) => meta.yOrdinalLabel?.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]);
}, [info.heatmap, info.exemplars]);
const palette = useMemo(() => quantizeScheme(options.color, theme), [options.color, theme]);
@ -97,6 +108,8 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
dataRef.current = info;
const builder = useMemo(() => {
const scaleConfig = dataRef.current?.heatmap?.fields[1].config?.custom
?.scaleDistribution as ScaleDistributionConfig;
return prepConfig({
dataRef,
theme,
@ -113,9 +126,10 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
getTimeRange: () => timeRangeRef.current,
palette,
cellGap: options.cellGap,
hideThreshold: options.hideThreshold,
hideThreshold: options.filterValues?.min, // eventually a better range
exemplarColor: options.exemplars?.color ?? 'rgba(255,0,255,0.7)',
yAxisReverse: options.yAxisReverse,
yAxisConfig: options.yAxis,
ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, data.structureRev]);

View File

@ -1,42 +1,31 @@
import {
DataFrame,
DataFrameType,
FieldType,
formattedValueToString,
getDisplayProcessor,
getFieldDisplayName,
getValueFormat,
GrafanaTheme2,
outerJoinDataFrames,
PanelData,
} from '@grafana/data';
import { calculateHeatmapFromData, bucketsToScanlines } from 'app/features/transformers/calculateHeatmap/heatmap';
import { HeatmapBucketLayout } from 'app/features/transformers/calculateHeatmap/models.gen';
import { HeatmapMode, PanelOptions } from './models.gen';
export const enum BucketLayout {
le = 'le',
ge = 'ge',
unknown = 'unknown', // unknown
}
import { PanelOptions } from './models.gen';
export interface HeatmapData {
heatmap?: DataFrame; // data we will render
exemplars?: DataFrame; // optionally linked exemplars
exemplarColor?: string;
yAxisValues?: Array<number | string | null>;
yLabelValues?: string[]; // matched ordinally to yAxisValues
matchByLabel?: string; // e.g. le, pod, etc.
xBucketSize?: number;
yBucketSize?: number;
xBucketCount?: number;
yBucketCount?: number;
xLayout?: BucketLayout;
yLayout?: BucketLayout;
xLayout?: HeatmapBucketLayout;
yLayout?: HeatmapBucketLayout;
// Print a heatmap cell value
display?: (v: number) => string;
@ -51,13 +40,11 @@ export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme
return {};
}
const { mode } = options;
const exemplars = data.annotations?.find((f) => f.name === 'exemplar');
if (mode === HeatmapMode.Calculate) {
if (options.calculate) {
// TODO, check for error etc
return getHeatmapData(calculateHeatmapFromData(frames, options.calculate ?? {}), exemplars, theme);
return getHeatmapData(calculateHeatmapFromData(frames, options.calculation ?? {}), exemplars, theme);
}
// Check for known heatmap types
@ -88,21 +75,7 @@ export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme
}
}
// Some datasources return values in ascending order and require math to know the deltas
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 {
matchByLabel,
yLabelValues: matchByLabel ? yFields.map((f) => f.labels?.[matchByLabel] ?? '') : undefined,
yAxisValues: yFields.map((f) => getFieldDisplayName(f, bucketHeatmap, frames)),
...getHeatmapData(scanlinesFrame, exemplars, theme),
};
return getHeatmapData(bucketsToScanlines({ ...options.bucket, frame: bucketHeatmap }), exemplars, theme);
}
const getSparseHeatmapData = (
@ -173,8 +146,18 @@ const getHeatmapData = (frame: DataFrame, exemplars: DataFrame | undefined, them
yBucketCount: yBinQty,
// TODO: improve heuristic
xLayout: xName === 'xMax' ? BucketLayout.le : xName === 'xMin' ? BucketLayout.ge : BucketLayout.unknown,
yLayout: yName === 'yMax' ? BucketLayout.le : yName === 'yMin' ? BucketLayout.ge : BucketLayout.unknown,
xLayout:
xName === 'xMax'
? HeatmapBucketLayout.le
: xName === 'xMin'
? HeatmapBucketLayout.ge
: HeatmapBucketLayout.unknown,
yLayout:
yName === 'yMax'
? HeatmapBucketLayout.le
: yName === 'yMin'
? HeatmapBucketLayout.ge
: HeatmapBucketLayout.unknown,
display: (v) => formattedValueToString(disp(v)),
};

View File

@ -25,14 +25,22 @@ describe('Heatmap Migrations', () => {
"overrides": Array [],
},
"options": Object {
"calculate": Object {
"xAxis": Object {
"bucket": Object {
"layout": "auto",
},
"calculate": true,
"calculation": Object {
"xBuckets": Object {
"mode": "count",
"value": "100",
},
"yAxis": Object {
"yBuckets": Object {
"mode": "count",
"value": "20",
"scale": Object {
"log": 2,
"type": "log",
},
"value": "3",
},
},
"cellGap": 2,
@ -40,6 +48,8 @@ describe('Heatmap Migrations', () => {
"color": Object {
"exponent": 0.5,
"fill": "dark-orange",
"max": 100,
"min": 5,
"mode": "scheme",
"scale": "exponential",
"scheme": "BuGn",
@ -48,17 +58,22 @@ describe('Heatmap Migrations', () => {
"exemplars": Object {
"color": "rgba(255,0,255,0.7)",
},
"filterValues": Object {
"min": 1e-9,
},
"legend": Object {
"show": true,
},
"mode": "calculate",
"showValue": "never",
"tooltip": Object {
"show": true,
"yHistogram": true,
},
"yAxisLabels": "auto",
"yAxisReverse": false,
"yAxis": Object {
"axisPlacement": "left",
"axisWidth": 400,
"reverse": false,
},
},
}
`);
@ -103,8 +118,8 @@ const oldHeatmap = {
colorScale: 'sqrt',
exponent: 0.5,
colorScheme: 'interpolateBuGn',
min: null,
max: null,
min: 5,
max: 100,
},
legend: {
show: true,
@ -119,10 +134,11 @@ const oldHeatmap = {
show: true,
format: 'short',
decimals: null,
logBase: 1,
splitFactor: null,
logBase: 2,
splitFactor: 3,
min: null,
max: null,
width: '400',
},
xBucketSize: null,
xBucketNumber: 100,

View File

@ -1,11 +1,12 @@
import { FieldConfigSource, PanelModel, PanelTypeChangedHandler } from '@grafana/data';
import { VisibilityMode } from '@grafana/schema';
import { AxisPlacement, ScaleDistribution, VisibilityMode } from '@grafana/schema';
import {
HeatmapBucketLayout,
HeatmapCalculationMode,
HeatmapCalculationOptions,
} from 'app/features/transformers/calculateHeatmap/models.gen';
import { HeatmapMode, PanelOptions, defaultPanelOptions, HeatmapColorMode } from './models.gen';
import { PanelOptions, defaultPanelOptions, HeatmapColorMode } from './models.gen';
import { colorSchemes } from './palettes';
/**
@ -29,36 +30,55 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS
overrides: [],
};
const mode = angular.dataFormat === 'tsbuckets' ? HeatmapMode.Aggregated : HeatmapMode.Calculate;
const calculate: HeatmapCalculationOptions = {
...defaultPanelOptions.calculate,
const calculate = angular.dataFormat === 'tsbuckets' ? false : true;
const calculation: HeatmapCalculationOptions = {
...defaultPanelOptions.calculation,
};
if (mode === HeatmapMode.Calculate) {
const oldYAxis = { logBase: 1, ...angular.yAxis };
if (calculate) {
if (angular.xBucketSize) {
calculate.xAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.xBucketSize}` };
calculation.xBuckets = { mode: HeatmapCalculationMode.Size, value: `${angular.xBucketSize}` };
} else if (angular.xBucketNumber) {
calculate.xAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.xBucketNumber}` };
calculation.xBuckets = { mode: HeatmapCalculationMode.Count, value: `${angular.xBucketNumber}` };
}
if (angular.yBucketSize) {
calculate.yAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.yBucketSize}` };
calculation.yBuckets = { mode: HeatmapCalculationMode.Size, value: `${angular.yBucketSize}` };
} else if (angular.xBucketNumber) {
calculate.yAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.yBucketNumber}` };
calculation.yBuckets = { mode: HeatmapCalculationMode.Count, value: `${angular.yBucketNumber}` };
}
if (oldYAxis.logBase > 1) {
calculation.yBuckets = {
mode: HeatmapCalculationMode.Count,
value: +oldYAxis.splitFactor > 0 ? `${oldYAxis.splitFactor}` : undefined,
scale: {
type: ScaleDistribution.Log,
log: oldYAxis.logBase,
},
};
}
}
const options: PanelOptions = {
mode,
calculate,
calculation,
color: {
...defaultPanelOptions.color,
steps: 128, // best match with existing colors
},
cellGap: asNumber(angular.cards?.cardPadding),
cellSize: asNumber(angular.cards?.cardRound),
yAxisLabels: angular.yBucketBound,
yAxisReverse: angular.reverseYBuckets,
yAxis: {
axisPlacement: oldYAxis.show === false ? AxisPlacement.Hidden : AxisPlacement.Left,
reverse: Boolean(angular.reverseYBuckets),
axisWidth: oldYAxis.width ? +oldYAxis.width : undefined,
},
bucket: {
layout: getHeatmapBucketLayout(angular.yBucketBound),
},
legend: {
show: Boolean(angular.legend.show),
},
@ -72,6 +92,10 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS
},
};
if (angular.hideZeroBuckets) {
options.filterValues = { ...defaultPanelOptions.filterValues }; // min: 1e-9
}
// Migrate color options
const color = angular.color;
switch (color?.mode) {
@ -92,10 +116,24 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS
break;
}
}
options.color.min = color.min;
options.color.max = color.max;
return { fieldConfig, options };
}
function getHeatmapBucketLayout(v?: string): HeatmapBucketLayout {
switch (v) {
case 'upper':
return HeatmapBucketLayout.ge;
case 'lower':
return HeatmapBucketLayout.le;
case 'middle':
return HeatmapBucketLayout.unknown;
}
return HeatmapBucketLayout.auto;
}
function asNumber(v: any): number | undefined {
const num = +v;
return isNaN(num) ? undefined : num;

View File

@ -3,17 +3,11 @@
// It is currenty hand written but will serve as the target for cuetsy
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
import { HideableFieldConfig, VisibilityMode } from '@grafana/schema';
import { HeatmapCalculationOptions } from 'app/features/transformers/calculateHeatmap/models.gen';
import { AxisConfig, AxisPlacement, HideableFieldConfig, ScaleDistributionConfig, VisibilityMode } from '@grafana/schema';
import { HeatmapBucketLayout, HeatmapCalculationOptions } from 'app/features/transformers/calculateHeatmap/models.gen';
export const modelVersion = Object.freeze([1, 0]);
export enum HeatmapMode {
Aggregated = 'agg',
Calculate = 'calculate',
Accumulated = 'acc', // accumulated
}
export enum HeatmapColorMode {
Opacity = 'opacity',
Scheme = 'scheme',
@ -36,6 +30,16 @@ export interface HeatmapColorOptions {
min?: number;
max?: number;
}
export interface YAxisConfig extends AxisConfig {
unit?: string;
reverse?: boolean;
decimals?: number;
}
export interface FilterValueRange {
min?: number;
max?: number;
}
export interface HeatmapTooltip {
show: boolean;
@ -49,19 +53,24 @@ export interface ExemplarConfig {
color: string;
}
export interface BucketOptions {
name?: string;
layout?: HeatmapBucketLayout;
}
export interface PanelOptions {
mode: HeatmapMode;
calculate?: boolean;
calculation?: HeatmapCalculationOptions;
color: HeatmapColorOptions;
calculate?: HeatmapCalculationOptions;
filterValues?: FilterValueRange; // was hideZeroBuckets
bucket?: BucketOptions;
showValue: VisibilityMode;
cellGap?: number; // was cardPadding
cellSize?: number; // was cardRadius
hideThreshold?: number; // was hideZeroBuckets
yAxisLabels?: string;
yAxisReverse?: boolean;
yAxis: YAxisConfig;
legend: HeatmapLegend;
tooltip: HeatmapTooltip;
@ -69,7 +78,7 @@ export interface PanelOptions {
}
export const defaultPanelOptions: PanelOptions = {
mode: HeatmapMode.Aggregated,
calculate: false,
color: {
mode: HeatmapColorMode.Scheme,
scheme: 'Oranges',
@ -78,6 +87,12 @@ export const defaultPanelOptions: PanelOptions = {
exponent: 0.5,
steps: 64,
},
bucket: {
layout: HeatmapBucketLayout.auto,
},
yAxis: {
axisPlacement: AxisPlacement.Left,
},
showValue: VisibilityMode.Auto,
tooltip: {
show: true,
@ -89,13 +104,14 @@ export const defaultPanelOptions: PanelOptions = {
exemplars: {
color: 'rgba(255,0,255,0.7)',
},
filterValues: {
min: 1e-9,
},
cellGap: 1,
};
export interface PanelFieldConfig extends HideableFieldConfig {
// TODO points vs lines etc
scaleDistribution?: ScaleDistributionConfig;
}
export const defaultPanelFieldConfig: PanelFieldConfig = {
// default to points?
};
export const defaultPanelFieldConfig: PanelFieldConfig = {};

View File

@ -1,20 +1,45 @@
import React from 'react';
import { FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { FieldConfigProperty, FieldType, identityOverrideProcessor, PanelPlugin } from '@grafana/data';
import { config } from '@grafana/runtime';
import { GraphFieldConfig } from '@grafana/schema';
import { AxisPlacement, GraphFieldConfig, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema';
import { addHideFrom, ScaleDistributionEditor } from '@grafana/ui/src/options/builder';
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
import { addHeatmapCalculationOptions } from 'app/features/transformers/calculateHeatmap/editor/helper';
import { HeatmapBucketLayout } from 'app/features/transformers/calculateHeatmap/models.gen';
import { HeatmapPanel } from './HeatmapPanel';
import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations';
import { PanelOptions, defaultPanelOptions, HeatmapMode, HeatmapColorMode, HeatmapColorScale } from './models.gen';
import { PanelOptions, defaultPanelOptions, HeatmapColorMode, HeatmapColorScale } from './models.gen';
import { colorSchemes, quantizeScheme } from './palettes';
import { HeatmapSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPanel)
.useFieldConfig({
disableStandardOptions: [FieldConfigProperty.Color, FieldConfigProperty.Thresholds],
// This keeps: unit, decimals, displayName
disableStandardOptions: [
FieldConfigProperty.Color,
FieldConfigProperty.Thresholds,
FieldConfigProperty.Min,
FieldConfigProperty.Max,
FieldConfigProperty.Mappings,
FieldConfigProperty.NoValue,
],
useCustomConfig: (builder) => {
builder.addCustomEditor<void, ScaleDistributionConfig>({
id: 'scaleDistribution',
path: 'scaleDistribution',
name: 'Y axis scale',
category: ['Heatmap'],
editor: ScaleDistributionEditor as any,
override: ScaleDistributionEditor as any,
defaultValue: { type: ScaleDistribution.Linear },
shouldApply: (f) => f.type === FieldType.number,
process: identityOverrideProcessor,
hideFromDefaults: true,
});
addHideFrom(builder); // for tooltip etc
},
})
.setPanelChangeHandler(heatmapChangedHandler)
.setMigrationHandler(heatmapMigrationHandler)
@ -24,23 +49,88 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
let category = ['Heatmap'];
builder.addRadio({
path: 'mode',
name: 'Data',
defaultValue: defaultPanelOptions.mode,
path: 'calculate',
name: 'Calculate from data',
defaultValue: defaultPanelOptions.calculate,
category,
settings: {
options: [
{ label: 'Aggregated', value: HeatmapMode.Aggregated },
{ label: 'Calculate', value: HeatmapMode.Calculate },
// { label: 'Accumulated', value: HeatmapMode.Accumulated, description: 'The query response values are accumulated' },
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
});
if (opts.mode === HeatmapMode.Calculate) {
addHeatmapCalculationOptions('calculate.', builder, opts.calculate, category);
if (opts.calculate) {
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'];
builder.addRadio({
path: 'yAxis.axisPlacement',
name: 'Placement',
defaultValue: defaultPanelOptions.yAxis.axisPlacement ?? AxisPlacement.Left,
category,
settings: {
options: [
{ label: 'Left', value: AxisPlacement.Left },
{ label: 'Right', value: AxisPlacement.Right },
{ label: 'Hidden', value: AxisPlacement.Hidden },
],
},
});
builder
.addNumberInput({
path: 'yAxis.axisWidth',
name: 'Axis width',
defaultValue: defaultPanelOptions.yAxis.axisWidth,
settings: {
placeholder: 'Auto',
min: 5, // smaller should just be hidden
},
category,
})
.addTextInput({
path: 'yAxis.axisLabel',
name: 'Axis label',
defaultValue: defaultPanelOptions.yAxis.axisLabel,
settings: {
placeholder: 'Auto',
},
category,
})
.addBooleanSwitch({
path: 'yAxis.reverse',
name: 'Reverse',
defaultValue: defaultPanelOptions.yAxis.reverse === true,
category,
});
category = ['Colors'];
builder.addRadio({
@ -152,9 +242,9 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
// },
// })
.addNumberInput({
path: 'hideThreshold',
path: 'filterValues.min',
name: 'Hide cell counts <=',
defaultValue: 1e-9,
defaultValue: defaultPanelOptions.filterValues?.min,
category,
})
.addSliderInput({
@ -166,37 +256,17 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
min: 0,
max: 25,
},
})
// .addSliderInput({
// name: 'Cell radius',
// path: 'cellRadius',
// defaultValue: defaultPanelOptions.cellRadius,
// category,
// settings: {
// min: 0,
// max: 100,
// },
// })
// .addRadio({
// path: 'yAxisLabels',
// name: 'Axis labels',
// defaultValue: 'auto',
// category,
// settings: {
// options: [
// { value: 'auto', label: 'Auto' },
// { value: 'middle', label: 'Middle' },
// { value: 'bottom', label: 'Bottom' },
// { value: 'top', label: 'Top' },
// ],
// },
// })
.addBooleanSwitch({
path: 'yAxisReverse',
name: 'Reverse buckets',
defaultValue: defaultPanelOptions.yAxisReverse === true,
category,
});
// .addSliderInput({
// name: 'Cell radius',
// path: 'cellRadius',
// defaultValue: defaultPanelOptions.cellRadius,
// category,
// settings: {
// min: 0,
// max: 100,
// },
// })
category = ['Tooltip'];

View File

@ -1,13 +1,16 @@
import { MutableRefObject, RefObject } from 'react';
import uPlot from 'uplot';
import { DataFrameType, GrafanaTheme2, TimeRange } from '@grafana/data';
import { DataFrameType, GrafanaTheme2, incrRoundDn, incrRoundUp, TimeRange } from '@grafana/data';
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '@grafana/schema';
import { UPlotConfigBuilder } from '@grafana/ui';
import { readHeatmapScanlinesCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { HeatmapBucketLayout } from 'app/features/transformers/calculateHeatmap/models.gen';
import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
import { BucketLayout, HeatmapData } from './fields';
import { HeatmapData } from './fields';
import { PanelFieldConfig, YAxisConfig } from './models.gen';
interface PathbuilderOpts {
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
@ -15,6 +18,7 @@ interface PathbuilderOpts {
hideThreshold?: number;
xAlign?: -1 | 0 | 1;
yAlign?: -1 | 0 | 1;
ySizeDivisor?: number;
disp: {
fill: {
values: (u: uPlot, seriesIndex: number) => number[];
@ -52,7 +56,8 @@ interface PrepConfigOpts {
exemplarColor: string;
cellGap?: number | null; // in css pixels
hideThreshold?: number;
yAxisReverse?: boolean;
yAxisConfig: YAxisConfig;
ySizeDivisor?: number;
}
export function prepConfig(opts: PrepConfigOpts) {
@ -68,7 +73,8 @@ export function prepConfig(opts: PrepConfigOpts) {
palette,
cellGap,
hideThreshold,
yAxisReverse,
yAxisConfig,
ySizeDivisor,
} = opts;
const pxRatio = devicePixelRatio;
@ -205,7 +211,10 @@ export function prepConfig(opts: PrepConfigOpts) {
theme: theme,
});
const shouldUseLogScale = heatmapType === DataFrameType.HeatmapSparse;
const yFieldConfig = dataRef.current?.heatmap?.fields[1]?.config?.custom as PanelFieldConfig | undefined;
const yScale = yFieldConfig?.scaleDistribution ?? { type: ScaleDistribution.Linear };
const yAxisReverse = Boolean(yAxisConfig.reverse);
const shouldUseLogScale = yScale.type !== ScaleDistribution.Linear || heatmapType === DataFrameType.HeatmapSparse;
builder.addScale({
scaleKey: 'y',
@ -215,38 +224,84 @@ export function prepConfig(opts: PrepConfigOpts) {
direction: yAxisReverse ? ScaleDirection.Down : ScaleDirection.Up,
// should be tweakable manually
distribution: shouldUseLogScale ? ScaleDistribution.Log : ScaleDistribution.Linear,
log: 2,
range: shouldUseLogScale
? undefined
: (u, dataMin, dataMax) => {
let bucketSize = dataRef.current?.yBucketSize;
log: yScale.log ?? 2,
range:
// sparse already accounts for le/ge by explicit yMin & yMax cell bounds, so use default log ranging
heatmapType === DataFrameType.HeatmapSparse
? undefined
: // dense and ordinal only have one of yMin|yMax|y, so expand range by one cell in the direction of le/ge/unknown
(u, dataMin, dataMax) => {
// logarithmic expansion
if (shouldUseLogScale) {
let yExp = u.scales['y'].log!;
if (bucketSize === 0) {
bucketSize = 1;
}
let minExpanded = false;
let maxExpanded = false;
if (bucketSize) {
if (dataRef.current?.yLayout === BucketLayout.le) {
dataMin -= bucketSize!;
} else if (dataRef.current?.yLayout === BucketLayout.ge) {
dataMax += bucketSize!;
} else {
dataMin -= bucketSize! / 2;
dataMax += bucketSize! / 2;
if (ySizeDivisor !== 1) {
let log = yExp === 2 ? Math.log2 : Math.log10;
let minLog = log(dataMin);
let maxLog = log(dataMax);
if (!Number.isInteger(minLog)) {
dataMin = yExp ** incrRoundDn(minLog, 1);
minExpanded = true;
}
if (!Number.isInteger(maxLog)) {
dataMax = yExp ** incrRoundUp(maxLog, 1);
maxExpanded = true;
}
}
if (dataRef.current?.yLayout === HeatmapBucketLayout.le) {
if (!minExpanded) {
dataMin /= yExp;
}
} else if (dataRef.current?.yLayout === HeatmapBucketLayout.ge) {
if (!maxExpanded) {
dataMax *= yExp;
}
} else {
dataMin /= yExp / 2;
dataMax *= yExp / 2;
}
}
} else {
// how to expand scale range if inferred non-regular or log buckets?
}
// linear expansion
else {
let bucketSize = dataRef.current?.yBucketSize;
return [dataMin, dataMax];
},
if (bucketSize === 0) {
bucketSize = 1;
}
if (bucketSize) {
if (dataRef.current?.yLayout === HeatmapBucketLayout.le) {
dataMin -= bucketSize!;
} else if (dataRef.current?.yLayout === HeatmapBucketLayout.ge) {
dataMax += bucketSize!;
} else {
dataMin -= bucketSize! / 2;
dataMax += bucketSize! / 2;
}
} else {
// how to expand scale range if inferred non-regular or log buckets?
}
}
return [dataMin, dataMax];
},
});
const hasLabeledY = dataRef.current?.yAxisValues != null;
const hasLabeledY = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap).yOrdinalDisplay != null;
builder.addAxis({
scaleKey: 'y',
placement: AxisPlacement.Left,
show: yAxisConfig.axisPlacement !== AxisPlacement.Hidden,
placement: yAxisConfig.axisPlacement || AxisPlacement.Left,
size: yAxisConfig.axisWidth || null,
label: yAxisConfig.axisLabel,
theme: theme,
splits: hasLabeledY
? () => {
@ -255,7 +310,7 @@ export function prepConfig(opts: PrepConfigOpts) {
const bucketSize = dataRef.current?.yBucketSize!;
if (dataRef.current?.yLayout === BucketLayout.le) {
if (dataRef.current?.yLayout === HeatmapBucketLayout.le) {
splits.unshift(ys[0] - bucketSize);
} else {
splits.push(ys[ys.length - 1] + bucketSize);
@ -266,12 +321,14 @@ export function prepConfig(opts: PrepConfigOpts) {
: undefined,
values: hasLabeledY
? () => {
const yAxisValues = dataRef.current?.yAxisValues?.slice()!;
const meta = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap);
const yAxisValues = meta.yOrdinalDisplay?.slice()!;
const isFromBuckets = meta.yOrdinalDisplay?.length && !('le' === meta.yMatchWithLabel);
if (dataRef.current?.yLayout === BucketLayout.le) {
yAxisValues.unshift('0.0'); // assumes dense layout where lowest bucket's low bound is 0-ish
} else if (dataRef.current?.yLayout === BucketLayout.ge) {
yAxisValues.push('+Inf');
if (dataRef.current?.yLayout === HeatmapBucketLayout.le) {
yAxisValues.unshift(isFromBuckets ? '' : '0.0'); // assumes dense layout where lowest bucket's low bound is 0-ish
} else if (dataRef.current?.yLayout === HeatmapBucketLayout.ge) {
yAxisValues.push(isFromBuckets ? '' : '+Inf');
}
return yAxisValues;
@ -307,12 +364,18 @@ export function prepConfig(opts: PrepConfigOpts) {
},
gap: cellGap,
hideThreshold,
xAlign: dataRef.current?.xLayout === BucketLayout.le ? -1 : dataRef.current?.xLayout === BucketLayout.ge ? 1 : 0,
yAlign: ((dataRef.current?.yLayout === BucketLayout.le
xAlign:
dataRef.current?.xLayout === HeatmapBucketLayout.le
? -1
: dataRef.current?.xLayout === HeatmapBucketLayout.ge
? 1
: 0,
yAlign: ((dataRef.current?.yLayout === HeatmapBucketLayout.le
? -1
: dataRef.current?.yLayout === BucketLayout.ge
: dataRef.current?.yLayout === HeatmapBucketLayout.ge
? 1
: 0) * (yAxisReverse ? -1 : 1)) as -1 | 0 | 1,
ySizeDivisor,
disp: {
fill: {
values: (u, seriesIdx) => {
@ -402,7 +465,7 @@ export function prepConfig(opts: PrepConfigOpts) {
const CRISP_EDGES_GAP_MIN = 4;
export function heatmapPathsDense(opts: PathbuilderOpts) {
const { disp, each, gap = 1, hideThreshold = 0, xAlign = 1, yAlign = 1 } = opts;
const { disp, each, gap = 1, hideThreshold = 0, xAlign = 1, yAlign = 1, ySizeDivisor = 1 } = opts;
const pxRatio = devicePixelRatio;
@ -451,8 +514,22 @@ export function heatmapPathsDense(opts: PathbuilderOpts) {
let xBinIncr = xs[yBinQty] - xs[0];
// uniform tile sizes based on zoom level
let xSize = Math.abs(valToPosX(xBinIncr, scaleX, xDim, xOff) - valToPosX(0, scaleX, xDim, xOff));
let ySize = Math.abs(valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff));
let xSize: number;
let ySize: number;
if (scaleX.distr === 3) {
xSize = Math.abs(valToPosX(xs[0] * scaleX.log!, scaleX, xDim, xOff) - valToPosX(xs[0], scaleX, xDim, xOff));
} else {
xSize = Math.abs(valToPosX(xBinIncr, scaleX, xDim, xOff) - valToPosX(0, scaleX, xDim, xOff));
}
if (scaleY.distr === 3) {
ySize =
Math.abs(valToPosY(ys[0] * scaleY.log!, scaleY, yDim, yOff) - valToPosY(ys[0], scaleY, yDim, yOff)) /
ySizeDivisor;
} else {
ySize = Math.abs(valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff)) / ySizeDivisor;
}
// clamp min tile size to 1px
xSize = Math.max(1, round(xSize - cellGap));