HeatmapNG: add log scale calculation (#49969)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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