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:
co-authored by
Drew Slobodnjak
parent
37c1e3fb02
commit
72e1f1e546
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Generated
+4
@@ -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 {
|
||||
|
||||
@@ -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;",
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
+14
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user