Heatmap: Support for linear y axis (#113337)

* wip

* boop

* Base factor on data

* Add some basic option control

* Remove old comments

* Add feature flag

* Apply feature flag to axis options

* Turn factor calculation into exported function

* Simplify bucket factor function

* Clarify comments

* Fix cell sizing of pre-bucketed heatmaps with log

* Remove unnecessary category change

* Consolidate editor for calculate from data no

* Update bucket function sanity checks

* Wire up scale config from yBucketScale

* Hide bucket controls for heatmap cells

* Fix splits

* Add test coverage

* Fix failing test

* Add basic util test coverage

* Fix tooltip for legacy in linear

* Fix y bucket option width to be consistent

* Hide tick alignment for explicit scale modes

* Clarify comment

* Make sure units are passed properly for linear

* Remove null assertion operator

* Clean up nested ternary

* Add type protection to scaleLog

* Remove repeated code for ySize calcs

* Remove ternary for scaleDistribution

* Add test coverage for YBucketScaleEditor

* Add isHeatmapSparse function to tooltip utils

* Create calculateYSizeDivisor util function

* Fix y axis min and max options and extend to log

* Add toLogBase test coverage

* Create applyExplicitMinMax function

* Add additional test coverage for scale editor

* Run i18n-extract

* Update eslint suppressions

---------

Co-authored-by: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com>
This commit is contained in:
Leon Sorokin
2025-12-18 14:45:00 -08:00
committed by GitHub
co-authored by Drew Slobodnjak
parent 37c1e3fb02
commit 72e1f1e546
20 changed files with 1481 additions and 77 deletions
+1 -1
View File
@@ -4339,7 +4339,7 @@
},
"public/app/plugins/panel/heatmap/utils.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 16
"count": 14
}
},
"public/app/plugins/panel/histogram/Histogram.tsx": {
+5
View File
@@ -1169,6 +1169,11 @@ export interface FeatureToggles {
*/
externalVizSuggestions?: boolean;
/**
* Enable Y-axis scale configuration options for pre-bucketed heatmap data (heatmap-rows)
* @default false
*/
heatmapRowsAxisOptions?: boolean;
/**
* Restrict PanelChrome contents with overflow: hidden;
* @default true
*/
@@ -185,6 +185,10 @@ export interface RowsHeatmapOptions {
* Sets the name of the cell when not calculating from data
*/
value?: string;
/**
* Controls the scale distribution of the y-axis buckets
*/
yBucketScale?: ui.ScaleDistributionConfig;
}
export interface Options {
+8
View File
@@ -1928,6 +1928,14 @@ var (
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "heatmapRowsAxisOptions",
Description: "Enable Y-axis scale configuration options for pre-bucketed heatmap data (heatmap-rows)",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "preventPanelChromeOverflow",
Description: "Restrict PanelChrome contents with overflow: hidden;",
+1
View File
@@ -262,6 +262,7 @@ pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,
newGauge,experimental,@grafana/dataviz-squad,false,false,true
newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
heatmapRowsAxisOptions,experimental,@grafana/dataviz-squad,false,false,true
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
262 newGauge experimental @grafana/dataviz-squad false false true
263 newVizSuggestions preview @grafana/dataviz-squad false false true
264 externalVizSuggestions experimental @grafana/dataviz-squad false false true
265 heatmapRowsAxisOptions experimental @grafana/dataviz-squad false false true
266 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
267 jaegerEnableGrpcEndpoint experimental @grafana/oss-big-tent false false false
268 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
+14
View File
@@ -1647,6 +1647,20 @@
"codeowner": "@grafana/search-and-storage"
}
},
{
"metadata": {
"name": "heatmapRowsAxisOptions",
"resourceVersion": "1765353244400",
"creationTimestamp": "2025-12-10T07:54:04Z"
},
"spec": {
"description": "Enable Y-axis scale configuration options for pre-bucketed heatmap data (heatmap-rows)",
"stage": "experimental",
"codeowner": "@grafana/dataviz-squad",
"frontend": true,
"expression": "false"
}
},
{
"metadata": {
"name": "improvedExternalSessionHandling",
@@ -1,7 +1,7 @@
import { FieldType, toDataFrame } from '@grafana/data';
import { HeatmapCalculationOptions } from '@grafana/schema';
import { HeatmapCalculationOptions, HeatmapCellLayout, ScaleDistribution } from '@grafana/schema';
import { rowsToCellsHeatmap, calculateHeatmapFromData } from './heatmap';
import { rowsToCellsHeatmap, calculateHeatmapFromData, calculateBucketFactor } from './heatmap';
describe('Heatmap transformer', () => {
it('calculate heatmap from input data', async () => {
@@ -121,4 +121,327 @@ describe('Heatmap transformer', () => {
})
).toThrowErrorMatchingInlineSnapshot(`"No numeric fields found for heatmap"`);
});
describe('calculateBucketFactor', () => {
it('calculates ratio from last two buckets for log2 spacing', () => {
const buckets = [1, 2, 4, 8];
expect(calculateBucketFactor(buckets)).toBe(2);
});
it('calculates ratio from last two buckets for log10 spacing', () => {
const buckets = [1, 10, 100, 1000];
expect(calculateBucketFactor(buckets)).toBe(10);
});
it('calculates ratio for non-uniform spacing', () => {
const buckets = [1, 2.5, 6.25];
expect(calculateBucketFactor(buckets)).toBe(2.5);
});
it('returns default factor for single value array', () => {
expect(calculateBucketFactor([5])).toBe(1.5);
});
it('returns default factor for empty array', () => {
expect(calculateBucketFactor([])).toBe(1.5);
});
it('returns default factor when ratio is not valid expansion (<=1)', () => {
const buckets = [10, 5]; // Descending
expect(calculateBucketFactor(buckets)).toBe(1.5);
});
it('returns default factor when ratio contains zero', () => {
const buckets = [0, 5];
expect(calculateBucketFactor(buckets)).toBe(1.5);
});
it('returns default factor when ratio is infinite', () => {
const buckets = [5, Infinity];
expect(calculateBucketFactor(buckets)).toBe(1.5);
});
it('accepts custom default factor', () => {
expect(calculateBucketFactor([5], 3)).toBe(3);
});
});
describe('rowsToCellsHeatmap with linear scale', () => {
it('converts prometheus-style le labels to numeric buckets with linear scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
{
name: '1',
type: FieldType.number,
labels: { le: '1' },
values: [10, 15],
},
{
name: '10',
type: FieldType.number,
labels: { le: '10' },
values: [20, 25],
},
{
name: '100',
type: FieldType.number,
labels: { le: '100' },
values: [30, 35],
},
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Linear },
});
expect(heatmap.fields[1].name).toBe('yMin');
expect(heatmap.fields[1].values).toEqual([1, 10, 100, 1, 10, 100]);
});
it('converts ge labels to numeric buckets with linear scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
{
name: '1',
type: FieldType.number,
labels: { ge: '1' },
values: [10, 15],
},
{
name: '10',
type: FieldType.number,
labels: { ge: '10' },
values: [20, 25],
},
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Linear },
layout: HeatmapCellLayout.ge,
});
expect(heatmap.fields[1].values).toEqual([1, 10, 1, 10]);
expect(heatmap.fields[1].name).toBe('yMin'); // ge layout
});
it('generates yMax field for linear scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: '1', type: FieldType.number, values: [10] },
{ name: '2', type: FieldType.number, values: [20] },
{ name: '4', type: FieldType.number, values: [30] },
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Linear },
});
// Should have yMin, yMax, and count fields
expect(heatmap.fields.length).toBe(4);
expect(heatmap.fields[2].name).toBe('yMax');
expect(heatmap.fields[2].type).toBe('number');
// yMax should be [2, 4, 8] (shifted buckets + calculated last bucket)
// Last bucket uses factor 2 (from 2→4) to estimate 4→8
expect(heatmap.fields[2].values).toEqual([2, 4, 8]);
});
it('clears yOrdinalDisplay for linear scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: '1', type: FieldType.number, values: [10] },
{ name: '10', type: FieldType.number, values: [20] },
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Linear },
});
expect(heatmap.meta?.custom?.yOrdinalDisplay).toBeUndefined();
});
it('clears yOrdinalDisplay for log scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: '1', type: FieldType.number, values: [10] },
{ name: '10', type: FieldType.number, values: [20] },
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Log, log: 10 },
});
expect(heatmap.meta?.custom?.yOrdinalDisplay).toBeUndefined();
});
it('clears yOrdinalDisplay for symlog scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: '1', type: FieldType.number, values: [10] },
{ name: '10', type: FieldType.number, values: [20] },
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Symlog, log: 10, linearThreshold: 1 },
});
expect(heatmap.meta?.custom?.yOrdinalDisplay).toBeUndefined();
});
it('preserves yOrdinalDisplay for non-numeric scale (auto/ordinal)', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: 'low', type: FieldType.number, values: [10] },
{ name: 'high', type: FieldType.number, values: [20] },
],
});
const heatmap = rowsToCellsHeatmap({ frame });
expect(heatmap.meta?.custom?.yOrdinalDisplay).toEqual(['low', 'high']);
});
it('sets unit to undefined for linear scale when no unit exists', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: '1', type: FieldType.number, values: [10] },
{ name: '10', type: FieldType.number, values: [20] },
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Linear },
});
// No unit → expect undefined (not 'short')
expect(heatmap.fields[1].config.unit).toBeUndefined();
});
it('passes through existing unit for linear scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: '1', type: FieldType.number, values: [10], config: { unit: 'ms' } },
{ name: '10', type: FieldType.number, values: [20], config: { unit: 'ms' } },
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Linear },
});
// Existing unit → pass through unchanged
expect(heatmap.fields[1].config.unit).toBe('ms');
});
it('sets unit to short for ordinal scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: 'low', type: FieldType.number, values: [10] },
{ name: 'high', type: FieldType.number, values: [20] },
],
});
const heatmap = rowsToCellsHeatmap({ frame });
expect(heatmap.fields[1].config.unit).toBe('short');
});
it('uses "count" as value field name for linear scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: '1', type: FieldType.number, values: [10] },
{ name: '10', type: FieldType.number, values: [20] },
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Linear },
});
// Without yMax, should be 3 fields: xMax, y/yMin/yMax, yMax, count
const valueField = heatmap.fields.find((f) => f.name === 'count');
expect(valueField).toBeDefined();
});
it('uses "Value" as field name for ordinal scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: 'low', type: FieldType.number, values: [10] },
{ name: 'high', type: FieldType.number, values: [20] },
],
});
const heatmap = rowsToCellsHeatmap({ frame });
const valueField = heatmap.fields.find((f) => f.name === 'Value');
expect(valueField).toBeDefined();
});
it('respects custom value field name for linear scale', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: '1', type: FieldType.number, values: [10] },
{ name: '10', type: FieldType.number, values: [20] },
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Linear },
value: 'Temperature',
});
const valueField = heatmap.fields.find((f) => f.name === 'Temperature');
expect(valueField).toBeDefined();
});
it('calculates yMax upper bound using bucket factor', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000] },
{ name: '1', type: FieldType.number, values: [10] },
{ name: '10', type: FieldType.number, values: [20] },
{ name: '100', type: FieldType.number, values: [30] },
],
});
const heatmap = rowsToCellsHeatmap({
frame,
yBucketScale: { type: ScaleDistribution.Linear },
});
// buckets: [1, 10, 100]
// yMax: [10, 100, 1000] - last one calculated as 100 * 10
const yMaxField = heatmap.fields.find((f) => f.name === 'yMax');
expect(yMaxField?.values).toEqual([10, 100, 1000]);
});
});
});
@@ -19,6 +19,7 @@ import { isLikelyAscendingVector } from '@grafana/data/internal';
import { t } from '@grafana/i18n';
import {
ScaleDistribution,
ScaleDistributionConfig,
HeatmapCellLayout,
HeatmapCalculationMode,
HeatmapCalculationOptions,
@@ -72,13 +73,36 @@ function parseNumeric(v?: string | null) {
return v === '+Inf' ? Infinity : v === '-Inf' ? -Infinity : +(v ?? 0);
}
/**
* Calculate the expansion factor from adjacent bucket values.
* This is used to estimate the size/bound of the next bucket based on the spacing of existing buckets.
*
* @param bucketValues - Array of bucket boundary values
* @param defaultFactor - Factor to use if ratio cannot be determined (default: 1.5 for 50% expansion)
* @returns The calculated or default expansion factor
*/
export function calculateBucketFactor(bucketValues: number[], defaultFactor = 1.5): number {
if (bucketValues.length >= 2) {
const last = bucketValues.at(-1)!;
const prev = bucketValues.at(-2)!;
const ratio = last / prev;
// Only use ratio if it represents expansion (>1) and is valid
if (ratio > 1 && Number.isFinite(ratio)) {
return ratio;
}
}
return defaultFactor;
}
export function sortAscStrInf(aName?: string | null, bName?: string | null) {
return parseNumeric(aName) - parseNumeric(bName);
}
export interface HeatmapRowsCustomMeta {
/** This provides the lookup values */
yOrdinalDisplay: string[];
yOrdinalDisplay?: string[];
yOrdinalLabel?: string[];
yMatchWithLabel?: string;
yMinDisplay?: string;
@@ -115,6 +139,7 @@ export interface RowsHeatmapOptions {
unit?: string;
decimals?: number;
layout?: HeatmapCellLayout;
yBucketScale?: ScaleDistributionConfig;
}
/** Given existing buckets, create a values style frame */
@@ -129,10 +154,19 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame {
throw new Error(t('heatmap.error.no-y-fields', 'No numeric fields found for heatmap'));
}
// Determine if we should use numeric scaling based on yBucketScale option
// Default to 'auto' behavior (ordinal) if not specified
const scaleType = opts.yBucketScale?.type;
const useNumericScale =
scaleType === ScaleDistribution.Linear ||
scaleType === ScaleDistribution.Log ||
scaleType === ScaleDistribution.Symlog;
// similar to initBins() below
const len = xValues.length * yFields.length;
const xs = new Array(len);
const ys = new Array(len);
const ys2 = useNumericScale ? new Array(len) : undefined;
const counts2 = new Array(len);
const counts = yFields.map((field) => field.values.slice());
@@ -144,21 +178,8 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame {
}
});
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) {
ys[i] = bucketBounds[yi];
if (yi === 0 && i >= bucketBounds.length) {
xi++;
}
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';
let ordinalFieldName = yFields[0].labels?.le != null ? 'yMax' : yFields[0].labels?.ge != null ? 'yMin' : 'y';
switch (opts.layout) {
case HeatmapCellLayout.le:
ordinalFieldName = 'yMax';
@@ -175,6 +196,45 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame {
yOrdinalDisplay: yFields.map((f) => getFieldDisplayName(f, opts.frame)),
yMatchWithLabel: Object.keys(yFields[0].labels ?? {})[0],
};
let bucketBounds: number[];
let bucketBoundsMax: number[] | undefined;
if (useNumericScale) {
// Numeric mode: use numeric bucket values
bucketBounds = yFields.map((field) => {
const labelKey = custom.yMatchWithLabel;
const labelValue = labelKey ? field.labels?.[labelKey] : undefined;
const valueStr = labelValue ?? field.name;
return Number(valueStr);
});
// Generate upper bounds: shift values + calculate last bucket
bucketBoundsMax = bucketBounds.slice();
bucketBoundsMax.shift();
const factor = calculateBucketFactor(bucketBounds);
bucketBoundsMax.push(bucketBounds[bucketBounds.length - 1] * factor);
custom.yMatchWithLabel = undefined;
} else {
// Auto mode: use ordinal indices like the original main branch behavior
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) {
ys[i] = bucketBounds[yi];
if (useNumericScale && ys2 && bucketBoundsMax) {
ys2[i] = bucketBoundsMax[yi];
}
if (yi === 0 && i >= bucketBounds.length) {
xi++;
}
xs[i] = xValues[xi];
}
if (custom.yMatchWithLabel) {
custom.yOrdinalLabel = yFields.map((f) => f.labels?.[custom.yMatchWithLabel!] ?? '');
if (custom.yMatchWithLabel === 'le') {
@@ -189,7 +249,7 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame {
if (custom.yMinDisplay) {
custom.yMinDisplay = formattedValueToString(fmt(0, opts.decimals));
}
custom.yOrdinalDisplay = custom.yOrdinalDisplay.map((name) => {
custom.yOrdinalDisplay = custom.yOrdinalDisplay?.map((name) => {
let num = +name;
if (!Number.isNaN(num)) {
@@ -200,6 +260,11 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame {
});
}
// Clear yOrdinalDisplay when using numeric scales (linear, log, symlog)
if (useNumericScale) {
custom.yOrdinalDisplay = undefined;
}
const valueCfg = {
...yFields[0].config,
};
@@ -208,6 +273,43 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame {
delete valueCfg.displayNameFromDS;
}
// Build fields array - only include yMax in linear scale mode
const fields: Field[] = [
{
name: xField.type === FieldType.time ? 'xMax' : 'x',
type: xField.type,
values: xs,
config: xField.config,
},
{
name: useNumericScale ? 'yMin' : ordinalFieldName,
type: FieldType.number,
values: ys,
config: {
unit: useNumericScale ? yFields[0]?.config?.unit : 'short', // preserve original unit for numeric, use 'short' for ordinal
},
},
];
// yMax provides explicit upper bounds for proper rendering, critical for ge layout
if (useNumericScale && ys2) {
fields.push({
name: 'yMax',
type: FieldType.number,
values: ys2,
config: {},
});
}
// Add value/count field
fields.push({
name: opts.value?.length ? opts.value : useNumericScale ? 'count' : 'Value',
type: FieldType.number,
values: counts2,
config: valueCfg,
display: yFields[0].display,
});
return {
length: xs.length,
refId: opts.frame.refId,
@@ -215,29 +317,7 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame {
type: DataFrameType.HeatmapCells,
custom,
},
fields: [
{
name: xField.type === FieldType.time ? 'xMax' : 'x',
type: xField.type,
values: xs,
config: xField.config,
},
{
name: ordinalFieldName,
type: FieldType.number,
values: ys,
config: {
unit: 'short', // ordinal lookup
},
},
{
name: opts.value?.length ? opts.value : 'Value',
type: FieldType.number,
values: counts2,
config: valueCfg,
display: yFields[0].display,
},
],
fields,
};
}
@@ -5,7 +5,6 @@ import { DashboardCursorSync, PanelProps, TimeRange } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { ScaleDistributionConfig } from '@grafana/schema';
import {
ScaleDistribution,
TooltipPlugin2,
TooltipDisplayMode,
UPlotChart,
@@ -29,7 +28,7 @@ import { HeatmapTooltip } from './HeatmapTooltip';
import { HeatmapData, prepareHeatmapData } from './fields';
import { quantizeScheme } from './palettes';
import { Options } from './types';
import { prepConfig } from './utils';
import { calculateYSizeDivisor, prepConfig } from './utils';
interface HeatmapPanelProps extends PanelProps<Options> {}
@@ -141,6 +140,16 @@ const HeatmapPanelViz = ({
const builder = useMemo(() => {
const scaleConfig: ScaleDistributionConfig = dataRef.current?.heatmap?.fields[1].config?.custom?.scaleDistribution;
const activeScaleConfig = options.rowsFrame?.yBucketScale ?? scaleConfig;
// For log/symlog scales: use 1 for pre-bucketed data with explicit scale, otherwise use split value
const hasExplicitScale = options.rowsFrame?.yBucketScale !== undefined;
const ySizeDivisor = calculateYSizeDivisor(
activeScaleConfig?.type,
hasExplicitScale,
options.calculation?.yBuckets?.value
);
return prepConfig({
dataRef,
theme,
@@ -151,9 +160,10 @@ const HeatmapPanelViz = ({
hideGE: options.filterValues?.ge,
exemplarColor: options.exemplars?.color ?? 'rgba(255,0,255,0.7)',
yAxisConfig: options.yAxis,
ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1,
ySizeDivisor,
selectionMode: options.selectionMode,
xAxisConfig: getXAxisConfig(annotationsLength),
rowsFrame: options.rowsFrame,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -4,7 +4,6 @@ import uPlot from 'uplot';
import {
ActionModel,
DataFrameType,
Field,
FieldType,
formattedValueToString,
@@ -26,7 +25,7 @@ import {
} from '@grafana/ui/internal';
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { getDisplayValuesAndLinks } from 'app/features/visualization/data-hover/DataHoverView';
import { ExemplarTooltip } from 'app/features/visualization/data-hover/ExemplarTooltip';
@@ -35,7 +34,13 @@ import { isTooltipScrollable } from '../timeseries/utils';
import { HeatmapData } from './fields';
import { renderHistogram } from './renderHistogram';
import { formatMilliseconds, getFieldFromData, getHoverCellColor, getSparseCellMinMax } from './tooltip/utils';
import {
formatMilliseconds,
getFieldFromData,
getHoverCellColor,
getSparseCellMinMax,
isHeatmapSparse,
} from './tooltip/utils';
interface HeatmapTooltipProps {
mode: TooltipDisplayMode;
@@ -99,9 +104,7 @@ const HeatmapHoverCell = ({
const index = dataIdxs[1]!;
const data = dataRef.current;
const [isSparse] = useState(
() => data.heatmap?.meta?.type === DataFrameType.HeatmapCells && !isHeatmapCellsDense(data.heatmap)
);
const [isSparse] = useState(() => isHeatmapSparse(data.heatmap));
const xField = getFieldFromData(data.heatmap!, 'x', isSparse)!;
const yField = getFieldFromData(data.heatmap!, 'y', isSparse)!;
@@ -0,0 +1,277 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import selectEvent from 'react-select-event';
import { StandardEditorContext, StandardEditorsRegistryItem } from '@grafana/data';
import { ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema';
import { YBucketScaleEditor } from './YBucketScaleEditor';
const mockContext: StandardEditorContext<unknown> = {
data: [],
};
const mockItem: StandardEditorsRegistryItem<ScaleDistributionConfig | undefined> = {
id: 'yBucketScale',
name: 'Y Bucket Scale',
editor: YBucketScaleEditor,
};
describe('YBucketScaleEditor', () => {
describe('Scale selection', () => {
it('should render with Auto selected when value is undefined', () => {
const onChange = jest.fn();
render(<YBucketScaleEditor value={undefined} onChange={onChange} context={mockContext} item={mockItem} />);
const autoButton = screen.getByRole('radio', { name: /auto/i });
expect(autoButton).toBeChecked();
});
it('should render with Linear selected when value is Linear', () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Linear }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
const linearButton = screen.getByRole('radio', { name: /linear/i });
expect(linearButton).toBeChecked();
});
it('should call onChange with undefined when Auto is selected', async () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Linear }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
const autoButton = screen.getByRole('radio', { name: /auto/i });
await userEvent.click(autoButton);
expect(onChange).toHaveBeenCalledWith(undefined);
});
it('should call onChange with Linear config when Linear is selected', async () => {
const onChange = jest.fn();
render(<YBucketScaleEditor value={undefined} onChange={onChange} context={mockContext} item={mockItem} />);
const linearButton = screen.getByRole('radio', { name: /linear/i });
await userEvent.click(linearButton);
expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Linear });
});
it('should call onChange with Log config when Log is selected', async () => {
const onChange = jest.fn();
render(<YBucketScaleEditor value={undefined} onChange={onChange} context={mockContext} item={mockItem} />);
const logButton = screen.getByRole('radio', { name: /^log$/i });
await userEvent.click(logButton);
expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Log, log: 2 });
});
it('should call onChange with Symlog config when Symlog is selected', async () => {
const onChange = jest.fn();
render(<YBucketScaleEditor value={undefined} onChange={onChange} context={mockContext} item={mockItem} />);
const symlogButton = screen.getByRole('radio', { name: /symlog/i });
await userEvent.click(symlogButton);
expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Symlog, log: 2, linearThreshold: 1 });
});
});
describe('Log base selection', () => {
it('should show log base selector for Log scale', () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Log, log: 2 }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
expect(screen.getByText('Log base')).toBeInTheDocument();
});
it('should show log base selector for Symlog scale', () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Symlog, log: 2, linearThreshold: 1 }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
expect(screen.getByText('Log base')).toBeInTheDocument();
});
it('should not show log base selector for Linear scale', () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Linear }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
expect(screen.queryByText('Log base')).not.toBeInTheDocument();
});
it('should not show log base selector for Auto', () => {
const onChange = jest.fn();
render(<YBucketScaleEditor value={undefined} onChange={onChange} context={mockContext} item={mockItem} />);
expect(screen.queryByText('Log base')).not.toBeInTheDocument();
});
it('should preserve existing log base when switching to Log', async () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Symlog, log: 10, linearThreshold: 1 }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
const logButton = screen.getByRole('radio', { name: /^log$/i });
await userEvent.click(logButton);
expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Log, log: 10 });
});
it('should update log base when changed for Log scale', async () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Log, log: 2 }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
// Find the log base field container and query the combobox within it
const logBaseLabel = screen.getByText('Log base');
const fieldContainer = logBaseLabel.closest('div[style]') as HTMLElement; // The div with style="margin-top: 8px;"
const selectEl = within(fieldContainer).getByRole('combobox');
await selectEvent.select(selectEl, '10', { container: document.body });
expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Log, log: 10 });
});
it('should update log base when changed for Symlog scale', async () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Symlog, log: 2, linearThreshold: 1 }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
// Find the log base field container and query the combobox within it
const logBaseLabel = screen.getByText('Log base');
const fieldContainer = logBaseLabel.closest('div[style]') as HTMLElement; // The div with style="margin-top: 8px;"
const selectEl = within(fieldContainer).getByRole('combobox');
await selectEvent.select(selectEl, '10', { container: document.body });
expect(onChange).toHaveBeenCalledWith({ type: ScaleDistribution.Symlog, log: 10, linearThreshold: 1 });
});
});
describe('Linear threshold', () => {
it('should show linear threshold input for Symlog scale', () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Symlog, log: 2, linearThreshold: 1 }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
expect(screen.getByText('Linear threshold')).toBeInTheDocument();
});
it('should not show linear threshold input for Log scale', () => {
const onChange = jest.fn();
render(
<YBucketScaleEditor
value={{ type: ScaleDistribution.Log, log: 2 }}
onChange={onChange}
context={mockContext}
item={mockItem}
/>
);
expect(screen.queryByText('Linear threshold')).not.toBeInTheDocument();
});
it('should not update linear threshold for a 0 value', async () => {
const onChange = jest.fn();
const origValue = { type: ScaleDistribution.Symlog, log: 10, linearThreshold: 1 };
render(<YBucketScaleEditor value={{ ...origValue }} onChange={onChange} context={mockContext} item={mockItem} />);
const input = screen.getByPlaceholderText('1');
await userEvent.clear(input);
await userEvent.type(input, '0');
expect(onChange).not.toHaveBeenCalled();
await userEvent.type(input, '.');
expect(onChange).not.toHaveBeenCalled();
await userEvent.type(input, '5');
expect(onChange).toHaveBeenCalledWith({ ...origValue, linearThreshold: 0.5 });
});
it('should update linear threshold for valid non-zero values', async () => {
const onChange = jest.fn();
const origValue = { type: ScaleDistribution.Symlog, log: 2, linearThreshold: 1 };
render(<YBucketScaleEditor value={{ ...origValue }} onChange={onChange} context={mockContext} item={mockItem} />);
const input = screen.getByPlaceholderText('1');
await userEvent.clear(input);
await userEvent.type(input, '5');
expect(onChange).toHaveBeenCalledWith({ ...origValue, linearThreshold: 5 });
});
it('should not dispatch onChange for invalid input', async () => {
const onChange = jest.fn();
const origValue = { type: ScaleDistribution.Symlog, log: 2, linearThreshold: 1 };
render(<YBucketScaleEditor value={{ ...origValue }} onChange={onChange} context={mockContext} item={mockItem} />);
const input = screen.getByPlaceholderText('1');
await userEvent.clear(input);
await userEvent.type(input, 'abc');
expect(onChange).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,135 @@
import { useState } from 'react';
import { SelectableValue, StandardEditorProps } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema';
import { RadioButtonGroup, Field, Select, Input } from '@grafana/ui';
type ScaleOptionValue = 'auto' | ScaleDistribution;
/**
* Simplified scale editor that shows all options in a single line.
* Includes "Auto" option which returns undefined to use default behavior.
*/
export const YBucketScaleEditor = (props: StandardEditorProps<ScaleDistributionConfig | undefined>) => {
const { value, onChange } = props;
const type = value?.type;
const log = value?.log ?? 2;
const isAuto = value === undefined;
const [localLinearThreshold, setLocalLinearThreshold] = useState<string>(
value?.linearThreshold != null ? String(value.linearThreshold) : ''
);
const currentOption: ScaleOptionValue = isAuto ? 'auto' : type!;
const showLogBase = type === ScaleDistribution.Log || type === ScaleDistribution.Symlog;
const showLinearThreshold = type === ScaleDistribution.Symlog;
const SCALE_OPTIONS: Array<SelectableValue<ScaleOptionValue>> = [
{
label: t('heatmap.y-bucket-scale-editor.scale-options.label-auto', 'Auto'),
value: 'auto',
},
{
label: t('heatmap.y-bucket-scale-editor.scale-options.label-linear', 'Linear'),
value: ScaleDistribution.Linear,
},
{
label: t('heatmap.y-bucket-scale-editor.scale-options.label-log', 'Log'),
value: ScaleDistribution.Log,
},
{
label: t('heatmap.y-bucket-scale-editor.scale-options.label-symlog', 'Symlog'),
value: ScaleDistribution.Symlog,
},
];
const LOG_BASE_OPTIONS: Array<SelectableValue<number>> = [
{
label: '2',
value: 2,
},
{
label: '10',
value: 10,
},
];
const handleScaleChange = (v: ScaleOptionValue) => {
if (v === 'auto') {
onChange(undefined);
return;
}
if (v === ScaleDistribution.Linear) {
onChange({ type: ScaleDistribution.Linear });
return;
}
if (v === ScaleDistribution.Log) {
onChange({ type: ScaleDistribution.Log, log });
return;
}
if (v === ScaleDistribution.Symlog) {
onChange({
type: ScaleDistribution.Symlog,
log,
linearThreshold: value?.linearThreshold ?? 1,
});
return;
}
};
const handleLogBaseChange = (newLog: number) => {
onChange({
...value!,
log: newLog,
});
};
const handleLinearThresholdChange = (newValue: string) => {
setLocalLinearThreshold(newValue);
const numValue = parseFloat(newValue);
if (!isNaN(numValue) && numValue !== 0) {
onChange({
...value!,
linearThreshold: numValue,
});
}
};
return (
<>
<RadioButtonGroup value={currentOption} options={SCALE_OPTIONS} onChange={handleScaleChange} />
{showLogBase && (
<Field
label={t('heatmap.y-bucket-scale-editor.log-base-label', 'Log base')}
style={{ marginTop: '8px' }}
noMargin
>
<Select options={LOG_BASE_OPTIONS} value={log} onChange={(v) => handleLogBaseChange(v.value!)} />
</Field>
)}
{showLinearThreshold && (
<Field
label={t('heatmap.y-bucket-scale-editor.linear-threshold-label', 'Linear threshold')}
description={t(
'heatmap.y-bucket-scale-editor.linear-threshold-description',
'Range within which the scale is linear'
)}
style={{ marginTop: '8px' }}
noMargin
>
<Input
type="number"
value={localLinearThreshold}
onChange={(e) => handleLinearThresholdChange(e.currentTarget.value)}
placeholder={t('heatmap.y-bucket-scale-editor.linear-threshold-placeholder', '1')}
/>
</Field>
)}
</>
);
};
+24 -2
View File
@@ -1,4 +1,11 @@
import { DataFrame, FieldConfigProperty, FieldType, identityOverrideProcessor, PanelPlugin } from '@grafana/data';
import {
DataFrame,
DataFrameType,
FieldConfigProperty,
FieldType,
identityOverrideProcessor,
PanelPlugin,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import {
@@ -15,6 +22,7 @@ import { addHeatmapCalculationOptions } from 'app/features/transformers/calculat
import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { HeatmapPanel } from './HeatmapPanel';
import { YBucketScaleEditor } from './YBucketScaleEditor';
import { prepareHeatmapData } from './fields';
import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations';
import { colorSchemes, quantizeScheme } from './palettes';
@@ -59,6 +67,7 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
const opts = context.options ?? defaultOptions;
let isOrdinalY = false;
const isHeatmapCells = context.data.some((frame) => frame.meta?.type === DataFrameType.HeatmapCells);
if (context.data.length > 0) {
try {
@@ -94,6 +103,17 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
addHeatmapCalculationOptions('calculation.', builder, opts.calculation, category);
}
if (!opts.calculate && !isHeatmapCells && config.featureToggles.heatmapRowsAxisOptions) {
builder.addCustomEditor({
id: 'rowsFrame-yBucketScale',
path: 'rowsFrame.yBucketScale',
name: t('heatmap.name-y-bucket-scale', 'Y bucket scale'),
category,
editor: YBucketScaleEditor,
defaultValue: undefined,
});
}
category = [t('heatmap.category-y-axis', 'Y Axis')];
builder
@@ -170,7 +190,9 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
category,
});
if (!opts.calculate) {
// Hide tick alignment for explicit scales - bucket boundaries are fixed by numeric labels
const hasExplicitScale = context.options?.rowsFrame?.yBucketScale !== undefined;
if (!opts.calculate && !hasExplicitScale) {
builder.addRadio({
path: 'rowsFrame.layout',
name: t('heatmap.name-tick-alignment', 'Tick alignment'),
@@ -105,6 +105,8 @@ composableKinds: PanelCfg: lineage: {
value?: string
// Controls tick alignment when not calculating from data
layout?: ui.HeatmapCellLayout
// Controls the scale distribution of the y-axis buckets
yBucketScale?: ui.ScaleDistributionConfig
} @cuetsy(kind="interface")
Options: {
annotations?: ui.VizAnnotations
+4
View File
@@ -183,6 +183,10 @@ export interface RowsHeatmapOptions {
* Sets the name of the cell when not calculating from data
*/
value?: string;
/**
* Controls the scale distribution of the y-axis buckets
*/
yBucketScale?: ui.ScaleDistributionConfig;
}
export interface Options {
@@ -0,0 +1,47 @@
import { DataFrameType, toDataFrame } from '@grafana/data';
import { isHeatmapSparse } from './utils';
describe('isHeatmapSparse', () => {
it('should return false when heatmap is undefined', () => {
expect(isHeatmapSparse(undefined)).toBe(false);
});
it('should return false for dense HeatmapCells (single Y field)', () => {
const heatmap = toDataFrame({
fields: [{ name: 'y', values: [] }],
meta: { type: DataFrameType.HeatmapCells },
});
expect(isHeatmapSparse(heatmap)).toBe(false);
});
it('should return true for sparse HeatmapCells (yMin and yMax fields)', () => {
const heatmap = toDataFrame({
fields: [
{ name: 'yMin', values: [] },
{ name: 'yMax', values: [] },
],
meta: { type: DataFrameType.HeatmapCells },
});
expect(isHeatmapSparse(heatmap)).toBe(true);
});
it('should return false for non-HeatmapCells data frames', () => {
const heatmap = toDataFrame({
fields: [{ name: 'Value', values: [] }],
meta: { type: DataFrameType.HeatmapRows },
});
expect(isHeatmapSparse(heatmap)).toBe(false);
});
it('should return false when meta is undefined', () => {
const heatmap = toDataFrame({
fields: [{ name: 'value', values: [] }],
});
expect(isHeatmapSparse(heatmap)).toBe(false);
});
});
@@ -1,4 +1,5 @@
import { DataFrame, Field } from '@grafana/data';
import { DataFrame, DataFrameType, Field } from '@grafana/data';
import { isHeatmapCellsDense } from 'app/features/transformers/calculateHeatmap/heatmap';
import { HeatmapData } from '../fields';
@@ -91,3 +92,14 @@ export const getSparseCellMinMax = (data: HeatmapData, index: number): BucketsMi
yBucketMax: yMax.values[index],
};
};
/**
* Determines if a heatmap DataFrame is sparse (has explicit yMin/yMax bounds).
* Sparse heatmaps have HeatmapCells type and are not dense.
*/
export function isHeatmapSparse(heatmap: DataFrame | undefined): boolean {
if (!heatmap) {
return false;
}
return heatmap.meta?.type === DataFrameType.HeatmapCells && !isHeatmapCellsDense(heatmap);
}
+372 -3
View File
@@ -1,5 +1,374 @@
describe('a test', () => {
it('has to have at least one test', () => {
expect(true).toBeTruthy();
import { ScaleDistribution } from '@grafana/schema';
import { applyExplicitMinMax, boundedMinMax, calculateYSizeDivisor, toLogBase, valuesToFills } from './utils';
describe('toLogBase', () => {
it('returns 10 when value is 10', () => {
expect(toLogBase(10)).toBe(10);
});
it('returns 2 when value is 2', () => {
expect(toLogBase(2)).toBe(2);
});
it('returns 2 (default) when value is undefined', () => {
expect(toLogBase(undefined)).toBe(2);
});
it('returns 2 (default) for invalid values', () => {
expect(toLogBase(5)).toBe(2);
expect(toLogBase(0)).toBe(2);
expect(toLogBase(-1)).toBe(2);
expect(toLogBase(100)).toBe(2);
});
});
describe('applyExplicitMinMax', () => {
it('returns original values when no explicit values provided', () => {
const [min, max] = applyExplicitMinMax(0, 100, undefined, undefined);
expect(min).toBe(0);
expect(max).toBe(100);
});
it('applies explicit min only', () => {
const [min, max] = applyExplicitMinMax(0, 100, 10, undefined);
expect(min).toBe(10);
expect(max).toBe(100);
});
it('applies explicit max only', () => {
const [min, max] = applyExplicitMinMax(0, 100, undefined, 90);
expect(min).toBe(0);
expect(max).toBe(90);
});
it('applies both explicit min and max', () => {
const [min, max] = applyExplicitMinMax(0, 100, 20, 80);
expect(min).toBe(20);
expect(max).toBe(80);
});
it('handles negative values', () => {
const [min, max] = applyExplicitMinMax(-50, 50, -10, 10);
expect(min).toBe(-10);
expect(max).toBe(10);
});
it('handles explicit min = 0', () => {
const [min, max] = applyExplicitMinMax(10, 100, 0, undefined);
expect(min).toBe(0);
expect(max).toBe(100);
});
it('handles explicit max = 0', () => {
const [min, max] = applyExplicitMinMax(-100, -10, undefined, 0);
expect(min).toBe(-100);
expect(max).toBe(0);
});
it('handles null scaleMin', () => {
const [min, max] = applyExplicitMinMax(null, 100, 10, undefined);
expect(min).toBe(10);
expect(max).toBe(100);
});
it('handles null scaleMax', () => {
const [min, max] = applyExplicitMinMax(0, null, undefined, 90);
expect(min).toBe(0);
expect(max).toBe(90);
});
it('preserves null when no explicit value provided', () => {
const [min, max] = applyExplicitMinMax(null, null, undefined, undefined);
expect(min).toBe(null);
expect(max).toBe(null);
});
});
describe('calculateYSizeDivisor', () => {
it('returns 1 for linear scale', () => {
expect(calculateYSizeDivisor(ScaleDistribution.Linear, false, 2)).toBe(1);
});
it('returns 1 for log scale with explicit scale', () => {
expect(calculateYSizeDivisor(ScaleDistribution.Log, true, 2)).toBe(1);
});
it('returns 1 for symlog scale with explicit scale', () => {
expect(calculateYSizeDivisor(ScaleDistribution.Symlog, true, 2)).toBe(1);
});
it('returns split value for log scale without explicit scale', () => {
expect(calculateYSizeDivisor(ScaleDistribution.Log, false, 2)).toBe(2);
expect(calculateYSizeDivisor(ScaleDistribution.Log, false, 4)).toBe(4);
});
it('returns split value for symlog scale without explicit scale', () => {
expect(calculateYSizeDivisor(ScaleDistribution.Symlog, false, 2)).toBe(2);
expect(calculateYSizeDivisor(ScaleDistribution.Symlog, false, 3)).toBe(3);
});
it('handles string split values', () => {
expect(calculateYSizeDivisor(ScaleDistribution.Log, false, '2')).toBe(2);
expect(calculateYSizeDivisor(ScaleDistribution.Log, false, '4')).toBe(4);
});
it('returns 1 when split value is undefined', () => {
expect(calculateYSizeDivisor(ScaleDistribution.Log, false, undefined)).toBe(1);
});
it('returns 1 when scale type is undefined', () => {
expect(calculateYSizeDivisor(undefined, false, 2)).toBe(1);
});
it('returns 1 for ordinal scale', () => {
expect(calculateYSizeDivisor(ScaleDistribution.Ordinal, false, 2)).toBe(1);
});
});
describe('boundedMinMax', () => {
describe('when min and max are not provided', () => {
it('calculates min and max from values', () => {
const values = [10, 20, 5, 30, 15];
const [min, max] = boundedMinMax(values);
expect(min).toBe(5);
expect(max).toBe(30);
});
it('handles single value', () => {
const values = [42];
const [min, max] = boundedMinMax(values);
expect(min).toBe(42);
expect(max).toBe(42);
});
it('handles negative values', () => {
const values = [-10, -20, -5, -30];
const [min, max] = boundedMinMax(values);
expect(min).toBe(-30);
expect(max).toBe(-5);
});
it('handles mixed positive and negative values', () => {
const values = [-10, 20, -5, 30];
const [min, max] = boundedMinMax(values);
expect(min).toBe(-10);
expect(max).toBe(30);
});
it('returns Infinity/-Infinity for empty array', () => {
const values: number[] = [];
const [min, max] = boundedMinMax(values);
expect(min).toBe(Infinity);
expect(max).toBe(-Infinity);
});
});
describe('when min is provided', () => {
it('uses provided min value', () => {
const values = [10, 20, 5, 30];
const [min, max] = boundedMinMax(values, 0);
expect(min).toBe(0);
expect(max).toBe(30);
});
it('uses provided min even if higher than data min', () => {
const values = [10, 20, 5, 30];
const [min, max] = boundedMinMax(values, 15);
expect(min).toBe(15);
expect(max).toBe(30);
});
});
describe('when max is provided', () => {
it('uses provided max value', () => {
const values = [10, 20, 5, 30];
const [min, max] = boundedMinMax(values, undefined, 50);
expect(min).toBe(5);
expect(max).toBe(50);
});
it('uses provided max even if lower than data max', () => {
const values = [10, 20, 5, 30];
const [min, max] = boundedMinMax(values, undefined, 25);
expect(min).toBe(5);
expect(max).toBe(25);
});
});
describe('when both min and max are provided', () => {
it('uses both provided values', () => {
const values = [10, 20, 5, 30];
const [min, max] = boundedMinMax(values, 0, 50);
expect(min).toBe(0);
expect(max).toBe(50);
});
});
describe('with hideLE filter', () => {
it('excludes values less than or equal to hideLE', () => {
const values = [5, 10, 15, 20, 25];
const [min, max] = boundedMinMax(values, undefined, undefined, 10);
expect(min).toBe(15);
expect(max).toBe(25);
});
it('excludes all values when hideLE is higher than all values', () => {
const values = [5, 10, 15];
const [min, max] = boundedMinMax(values, undefined, undefined, 20);
expect(min).toBe(Infinity);
expect(max).toBe(-Infinity);
});
});
describe('with hideGE filter', () => {
it('excludes values greater than or equal to hideGE', () => {
const values = [5, 10, 15, 20, 25];
const [min, max] = boundedMinMax(values, undefined, undefined, -Infinity, 20);
expect(min).toBe(5);
expect(max).toBe(15);
});
it('excludes all values when hideGE is lower than all values', () => {
const values = [15, 20, 25];
const [min, max] = boundedMinMax(values, undefined, undefined, -Infinity, 10);
expect(min).toBe(Infinity);
expect(max).toBe(-Infinity);
});
});
describe('with both hideLE and hideGE filters', () => {
it('excludes values outside the range', () => {
const values = [5, 10, 15, 20, 25, 30];
const [min, max] = boundedMinMax(values, undefined, undefined, 10, 25);
expect(min).toBe(15);
expect(max).toBe(20);
});
it('works with provided min/max bounds', () => {
const values = [5, 10, 15, 20, 25, 30];
const [min, max] = boundedMinMax(values, 0, 50, 10, 25);
expect(min).toBe(0);
expect(max).toBe(50);
});
});
});
describe('valuesToFills', () => {
// Fake color palette for testing index mapping
const palette5 = ['c0', 'c1', 'c2', 'c3', 'c4'];
describe('basic mapping', () => {
it('maps values to palette indices', () => {
const values = [0, 25, 50, 75, 100];
const fills = valuesToFills(values, palette5, 0, 100);
expect(fills).toEqual([0, 1, 2, 3, 4]);
});
it('maps min value to first palette index', () => {
const values = [10];
const fills = valuesToFills(values, palette5, 10, 20);
expect(fills[0]).toBe(0);
});
it('maps max value to last palette index', () => {
const values = [20];
const fills = valuesToFills(values, palette5, 10, 20);
expect(fills[0]).toBe(4);
});
it('maps mid-range values proportionally', () => {
const values = [15];
const fills = valuesToFills(values, palette5, 10, 20);
// 15 is middle of 10-20, should map to index 2 (middle color)
expect(fills[0]).toBe(2);
});
});
describe('edge cases', () => {
it('clamps values below min to first index', () => {
const values = [5, 8, 10];
const fills = valuesToFills(values, palette5, 10, 20);
expect(fills[0]).toBe(0); // 5 < 10
expect(fills[1]).toBe(0); // 8 < 10
});
it('clamps values above max to last index', () => {
const values = [20, 25, 30];
const fills = valuesToFills(values, palette5, 10, 20);
expect(fills[0]).toBe(4); // 20 = max
expect(fills[1]).toBe(4); // 25 > max
expect(fills[2]).toBe(4); // 30 > max
});
it('handles zero range (min equals max)', () => {
const values = [10, 10, 10];
const fills = valuesToFills(values, palette5, 10, 10);
// When range is 0, defaults to 1, so all values map to 0
expect(fills).toEqual([0, 0, 0]);
});
it('handles single color palette', () => {
const values = [0, 50, 100];
const palette = ['c0'];
const fills = valuesToFills(values, palette, 0, 100);
expect(fills).toEqual([0, 0, 0]);
});
it('handles large palette', () => {
const values = [50];
const palette = Array.from({ length: 256 }, (_, i) => `c${i}`);
const fills = valuesToFills(values, palette, 0, 100);
// 50 is 50% of 0-100, should map to 128 (middle of 256)
expect(fills[0]).toBe(128);
});
});
describe('negative values', () => {
it('handles negative min and max', () => {
const values = [-10, -5, 0];
const palette = ['c0', 'c1', 'c2'];
const fills = valuesToFills(values, palette, -10, 0);
expect(fills[0]).toBe(0); // -10 is min
expect(fills[1]).toBe(1); // -5 is middle
expect(fills[2]).toBe(2); // 0 is max
});
it('handles range crossing zero', () => {
const values = [-10, 0, 10];
const palette = ['c0', 'c1', 'c2'];
const fills = valuesToFills(values, palette, -10, 10);
expect(fills[0]).toBe(0); // -10 is min
expect(fills[1]).toBe(1); // 0 is middle
expect(fills[2]).toBe(2); // 10 is max
});
});
describe('preserves array length', () => {
it('returns array with same length as input', () => {
const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const palette = ['c0', 'c1'];
const fills = valuesToFills(values, palette, 1, 10);
expect(fills.length).toBe(values.length);
});
it('handles empty array', () => {
const values: number[] = [];
const fills = valuesToFills(values, palette5, 0, 100);
expect(fills).toEqual([]);
});
});
});
+95 -20
View File
@@ -14,13 +14,22 @@ import {
} from '@grafana/data';
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation, HeatmapCellLayout } from '@grafana/schema';
import { UPlotConfigBuilder, UPlotConfigPrepFn } from '@grafana/ui';
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import {
calculateBucketFactor,
isHeatmapCellsDense,
readHeatmapRowsCustomMeta,
} from 'app/features/transformers/calculateHeatmap/heatmap';
import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
import { HeatmapData } from './fields';
import { FieldConfig, HeatmapSelectionMode, YAxisConfig } from './types';
/** Validates and returns a safe log base (2 or 10), defaults to 2 if invalid */
export function toLogBase(value: number | undefined): 2 | 10 {
return value === 10 ? 10 : 2;
}
interface PathbuilderOpts {
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
gap?: number | null;
@@ -54,6 +63,7 @@ interface PrepConfigOpts {
ySizeDivisor?: number;
selectionMode?: HeatmapSelectionMode;
xAxisConfig?: Parameters<UPlotConfigPrepFn>[0]['xAxisConfig'];
rowsFrame?: { yBucketScale?: { type: ScaleDistribution; log?: number; linearThreshold?: number } };
}
export function prepConfig(opts: PrepConfigOpts) {
@@ -69,8 +79,11 @@ export function prepConfig(opts: PrepConfigOpts) {
ySizeDivisor,
selectionMode = HeatmapSelectionMode.X,
xAxisConfig,
rowsFrame,
} = opts;
const yBucketScale = rowsFrame?.yBucketScale;
const xScaleKey = 'x';
let isTime = true;
@@ -196,7 +209,20 @@ export function prepConfig(opts: PrepConfigOpts) {
const yScale = yFieldConfig?.scaleDistribution ?? { type: ScaleDistribution.Linear };
const yAxisReverse = Boolean(yAxisConfig.reverse);
const isSparseHeatmap = heatmapType === DataFrameType.HeatmapCells && !isHeatmapCellsDense(dataRef.current?.heatmap!);
const shouldUseLogScale = yScale.type !== ScaleDistribution.Linear || isSparseHeatmap;
const scaleDistribution = (() => {
if (yBucketScale) {
return yBucketScale.type;
}
if (yScale.type !== ScaleDistribution.Linear || isSparseHeatmap) {
return ScaleDistribution.Log;
}
return ScaleDistribution.Linear;
})();
const scaleLog = toLogBase(yBucketScale?.log ?? yScale.log);
const scaleLinearThreshold = yBucketScale?.linearThreshold;
const isOrdinalY = readHeatmapRowsCustomMeta(dataRef.current?.heatmap).yOrdinalDisplay != null;
// random to prevent syncing y in other heatmaps
@@ -210,8 +236,9 @@ export function prepConfig(opts: PrepConfigOpts) {
orientation: ScaleOrientation.Vertical,
direction: yAxisReverse ? ScaleDirection.Down : ScaleDirection.Up,
// should be tweakable manually
distribution: shouldUseLogScale ? ScaleDistribution.Log : ScaleDistribution.Linear,
log: yScale.log ?? 2,
distribution: scaleDistribution,
log: scaleLog,
linearThreshold: scaleLinearThreshold,
range:
// sparse already accounts for le/ge by explicit yMin & yMax cell bounds, so no need to expand y range
isSparseHeatmap
@@ -224,16 +251,16 @@ export function prepConfig(opts: PrepConfigOpts) {
let scaleMin: number | null, scaleMax: number | null;
[scaleMin, scaleMax] = shouldUseLogScale
? uPlot.rangeLog(dataMin, dataMax, (yScale.log ?? 2) as unknown as uPlot.Scale.LogBase, true)
: [dataMin, dataMax];
const isLogScale =
scaleDistribution === ScaleDistribution.Log || scaleDistribution === ScaleDistribution.Symlog;
[scaleMin, scaleMax] = isLogScale ? uPlot.rangeLog(dataMin, dataMax, scaleLog, true) : [dataMin, dataMax];
if (shouldUseLogScale && !isOrdinalY) {
let { min: explicitMin, max: explicitMax } = yAxisConfig;
if (isLogScale && !isOrdinalY) {
let yExp = u.scales[yScaleKey].log!;
let log = yExp === 2 ? Math.log2 : Math.log10;
let { min: explicitMin, max: explicitMax } = yAxisConfig;
// guard against <= 0
if (explicitMin != null && explicitMin > 0) {
// snap to magnitude
@@ -245,6 +272,9 @@ export function prepConfig(opts: PrepConfigOpts) {
let maxLog = log(explicitMax);
scaleMax = yExp ** incrRoundUp(maxLog, 1);
}
} else if (!isOrdinalY) {
// Apply explicit min/max for linear scale
[scaleMin, scaleMax] = applyExplicitMinMax(scaleMin, scaleMax, explicitMin, explicitMax);
}
return [scaleMin, scaleMax];
@@ -257,7 +287,7 @@ export function prepConfig(opts: PrepConfigOpts) {
let { min: explicitMin, max: explicitMax } = yAxisConfig;
// logarithmic expansion
if (shouldUseLogScale) {
if (scaleDistribution === ScaleDistribution.Log || scaleDistribution === ScaleDistribution.Symlog) {
let yExp = u.scales[yScaleKey].log!;
let minExpanded = false;
@@ -280,17 +310,31 @@ export function prepConfig(opts: PrepConfigOpts) {
}
}
// For pre-bucketed data with explicit scale, calculate expansion factor from actual bucket spacing
// For calculated heatmaps, use the full log base
let expansionFactor: number = yExp;
if (yBucketScale !== undefined) {
// Try to infer the bucket factor from the actual data spacing
const yValues = u.data[1]?.[1];
if (Array.isArray(yValues) && yValues.length >= 2 && typeof yValues[0] === 'number') {
expansionFactor = calculateBucketFactor(yValues, yExp);
}
}
if (dataRef.current?.yLayout === HeatmapCellLayout.le) {
if (!minExpanded) {
scaleMin /= yExp;
scaleMin /= expansionFactor;
}
} else if (dataRef.current?.yLayout === HeatmapCellLayout.ge) {
if (!maxExpanded) {
scaleMax *= yExp;
scaleMax *= expansionFactor;
}
} else {
scaleMin /= yExp / 2;
scaleMax *= yExp / 2;
// Unknown layout - expand both directions
const factor = Math.sqrt(expansionFactor); // Use sqrt for balanced expansion
scaleMin /= factor;
scaleMax *= factor;
}
if (!isOrdinalY) {
@@ -383,7 +427,7 @@ export function prepConfig(opts: PrepConfigOpts) {
return splits.map((v) =>
v < 0
? (meta.yMinDisplay ?? '') // Check prometheus style labels
: (meta.yOrdinalDisplay[v] ?? '')
: (meta.yOrdinalDisplay?.[v] ?? '')
);
}
return splits;
@@ -585,15 +629,19 @@ export function heatmapPathsDense(opts: PathbuilderOpts) {
let ySize: number;
if (scaleX.distr === 3) {
xSize = Math.abs(valToPosX(xs[0] * scaleX.log!, scaleX, xDim, xOff) - valToPosX(xs[0], scaleX, xDim, xOff));
// For log scales, calculate cell size from actual adjacent bucket positions
const nextXValue = xs[yBinQty] ?? xs[0] * scaleX.log!;
xSize = Math.abs(valToPosX(nextXValue, 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;
// Use actual data spacing for pre-bucketed data, or full magnitude for calculated heatmaps with splits
const nextYValue = ySizeDivisor === 1 ? (ys[1] ?? ys[0] * scaleY.log!) : ys[0] * scaleY.log!;
const baseYSize = Math.abs(valToPosY(nextYValue, scaleY, yDim, yOff) - valToPosY(ys[0], scaleY, yDim, yOff));
ySize = baseYSize / ySizeDivisor;
} else {
ySize = Math.abs(valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff)) / ySizeDivisor;
}
@@ -882,3 +930,30 @@ export const valuesToFills = (values: number[], palette: string[], minValue: num
return indexedFills;
};
/**
* Calculates the Y-axis size divisor for heatmap cell rendering.
* For log/symlog scales with calculated data (no explicit scale), divides cells by the split value.
* Otherwise returns 1 (no division).
*/
export function calculateYSizeDivisor(
scaleType: ScaleDistribution | undefined,
hasExplicitScale: boolean,
splitValue: number | string | undefined
): number {
const isLogScale = scaleType === ScaleDistribution.Log || scaleType === ScaleDistribution.Symlog;
return isLogScale && !hasExplicitScale ? +(splitValue || 1) : 1;
}
/**
* Applies explicit min/max values to scale range for linear scales.
* Returns the original values if explicitMin/explicitMax are undefined.
*/
export function applyExplicitMinMax(
scaleMin: number | null,
scaleMax: number | null,
explicitMin: number | undefined,
explicitMax: number | undefined
): [number | null, number | null] {
return [explicitMin ?? scaleMin, explicitMax ?? scaleMax];
}
+13
View File
@@ -9361,6 +9361,7 @@
"name-unit": "Unit",
"name-value-name": "Value name",
"name-y-axis-scale": "Y axis scale",
"name-y-bucket-scale": "Y bucket scale",
"placeholder-axis-label": "Auto",
"placeholder-axis-width": "Auto",
"placeholder-decimals": "Auto",
@@ -9390,6 +9391,18 @@
"label-all": "All",
"label-hidden": "Hidden",
"label-single": "Single"
},
"y-bucket-scale-editor": {
"linear-threshold-description": "Range within which the scale is linear",
"linear-threshold-label": "Linear threshold",
"linear-threshold-placeholder": "1",
"log-base-label": "Log base",
"scale-options": {
"label-auto": "Auto",
"label-linear": "Linear",
"label-log": "Log",
"label-symlog": "Symlog"
}
}
},
"help-modal": {