mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Heatmap: new panel based based on uPlot (#44080)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
@@ -285,15 +285,24 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform
|
||||
};
|
||||
}
|
||||
|
||||
// function incrRound(num: number, incr: number) {
|
||||
// return Math.round(num / incr) * incr;
|
||||
// }
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function incrRound(num: number, incr: number) {
|
||||
return Math.round(num / incr) * incr;
|
||||
}
|
||||
|
||||
// function incrRoundUp(num: number, incr: number) {
|
||||
// return Math.ceil(num / incr) * incr;
|
||||
// }
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function incrRoundUp(num: number, incr: number) {
|
||||
return Math.ceil(num / incr) * incr;
|
||||
}
|
||||
|
||||
function incrRoundDn(num: number, incr: number) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function incrRoundDn(num: number, incr: number) {
|
||||
return Math.floor(num / incr) * incr;
|
||||
}
|
||||
|
||||
|
@@ -28,6 +28,7 @@ export enum DataTransformerID {
|
||||
prepareTimeSeries = 'prepareTimeSeries',
|
||||
convertFieldType = 'convertFieldType',
|
||||
fieldLookup = 'fieldLookup',
|
||||
heatmap = 'heatmap',
|
||||
spatial = 'spatial',
|
||||
extractFields = 'extractFields',
|
||||
}
|
||||
|
@@ -8,4 +8,17 @@ export enum DataFrameType {
|
||||
TimeSeriesWide = 'timeseries-wide',
|
||||
TimeSeriesLong = 'timeseries-long',
|
||||
TimeSeriesMany = 'timeseries-many',
|
||||
|
||||
/**
|
||||
* First field is X, the rest are bucket values
|
||||
*/
|
||||
HeatmapBuckets = 'heatmap-buckets',
|
||||
|
||||
/**
|
||||
* Explicit fields for:
|
||||
* xMin, yMin, count, ...
|
||||
*
|
||||
* All values in the grid exist and have regular spacing
|
||||
*/
|
||||
HeatmapScanlines = 'heatmap-scanlines',
|
||||
}
|
||||
|
@@ -16,6 +16,7 @@ import (
|
||||
ptable "github.com/grafana/grafana/public/app/plugins/panel/table:grafanaschema"
|
||||
ptext "github.com/grafana/grafana/public/app/plugins/panel/text:grafanaschema"
|
||||
ptimeseries "github.com/grafana/grafana/public/app/plugins/panel/timeseries:grafanaschema"
|
||||
pheatmap_new "github.com/grafana/grafana/public/app/plugins/panel/heatmap-new:grafanaschema"
|
||||
)
|
||||
|
||||
// Family composes the base dashboard scuemata family with all Grafana core plugins -
|
||||
@@ -40,5 +41,6 @@ Family: dashboard.Family & {
|
||||
text: ptext.Panel
|
||||
table: ptable.Panel
|
||||
timeseries: ptimeseries.Panel
|
||||
"heatmap-new": pheatmap_new.Panel
|
||||
}
|
||||
}
|
@@ -43,6 +43,7 @@ var skipPaths = []string{
|
||||
"public/app/plugins/panel/dashlist/models.cue",
|
||||
"public/app/plugins/panel/gauge/models.cue",
|
||||
"public/app/plugins/panel/histogram/models.cue",
|
||||
"public/app/plugins/panel/heatmap-new/models.cue",
|
||||
"public/app/plugins/panel/stat/models.cue",
|
||||
"public/app/plugins/panel/candlestick/models.cue",
|
||||
"public/app/plugins/panel/state-timeline/models.cue",
|
||||
|
@@ -119,6 +119,7 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
|
||||
"gettingstarted": {},
|
||||
"graph": {},
|
||||
"heatmap": {},
|
||||
"heatmap-new": {},
|
||||
"histogram": {},
|
||||
"icon": {},
|
||||
"live": {},
|
||||
|
@@ -0,0 +1,49 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
PanelOptionsEditorBuilder,
|
||||
PluginState,
|
||||
StandardEditorContext,
|
||||
TransformerRegistryItem,
|
||||
TransformerUIProps,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { HeatmapTransformerOptions, heatmapTransformer } from './heatmap';
|
||||
import { addHeatmapCalculationOptions } from './editor/helper';
|
||||
import { getDefaultOptions, getTransformerOptionPane } from '../spatial/optionsHelper';
|
||||
|
||||
// Nothing defined in state
|
||||
const supplier = (
|
||||
builder: PanelOptionsEditorBuilder<HeatmapTransformerOptions>,
|
||||
context: StandardEditorContext<HeatmapTransformerOptions>
|
||||
) => {
|
||||
const options = context.options ?? {};
|
||||
|
||||
addHeatmapCalculationOptions('', builder, options);
|
||||
};
|
||||
|
||||
export const HeatmapTransformerEditor: React.FC<TransformerUIProps<HeatmapTransformerOptions>> = (props) => {
|
||||
useEffect(() => {
|
||||
if (!props.options.xAxis?.mode) {
|
||||
const opts = getDefaultOptions(supplier);
|
||||
props.onChange({ ...opts, ...props.options });
|
||||
console.log('geometry useEffect', opts);
|
||||
}
|
||||
});
|
||||
|
||||
// Shared with spatial transformer
|
||||
const pane = getTransformerOptionPane<HeatmapTransformerOptions>(props, supplier);
|
||||
return (
|
||||
<div>
|
||||
<div>{pane.items.map((v) => v.render())}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const heatmapTransformRegistryItem: TransformerRegistryItem<HeatmapTransformerOptions> = {
|
||||
id: heatmapTransformer.id,
|
||||
editor: HeatmapTransformerEditor,
|
||||
transformation: heatmapTransformer,
|
||||
name: heatmapTransformer.name,
|
||||
description: heatmapTransformer.description,
|
||||
state: PluginState.alpha,
|
||||
};
|
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { HorizontalGroup, Input, RadioButtonGroup } from '@grafana/ui';
|
||||
import { HeatmapCalculationAxisConfig, HeatmapCalculationMode } from '../models.gen';
|
||||
|
||||
const modeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
|
||||
{
|
||||
label: 'Size',
|
||||
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<HeatmapCalculationAxisConfig, any>> = ({
|
||||
value,
|
||||
onChange,
|
||||
item,
|
||||
}) => {
|
||||
return (
|
||||
<HorizontalGroup>
|
||||
<RadioButtonGroup
|
||||
value={value?.mode || HeatmapCalculationMode.Size}
|
||||
options={modeOptions}
|
||||
onChange={(mode) => {
|
||||
onChange({
|
||||
...value,
|
||||
mode,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={value?.value ?? ''}
|
||||
placeholder="Auto"
|
||||
onChange={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
value: v.currentTarget.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
);
|
||||
};
|
@@ -0,0 +1,33 @@
|
||||
import { PanelOptionsEditorBuilder } from '@grafana/data';
|
||||
|
||||
import { HeatmapCalculationMode, HeatmapCalculationOptions } from '../models.gen';
|
||||
import { AxisEditor } from './AxisEditor';
|
||||
|
||||
export function addHeatmapCalculationOptions(
|
||||
prefix: string,
|
||||
builder: PanelOptionsEditorBuilder<any>,
|
||||
source?: HeatmapCalculationOptions,
|
||||
category?: string[]
|
||||
) {
|
||||
builder.addCustomEditor({
|
||||
id: 'xAxis',
|
||||
path: `${prefix}xAxis`,
|
||||
name: 'X Buckets',
|
||||
editor: AxisEditor,
|
||||
category,
|
||||
defaultValue: {
|
||||
mode: HeatmapCalculationMode.Size,
|
||||
},
|
||||
});
|
||||
|
||||
builder.addCustomEditor({
|
||||
id: 'yAxis',
|
||||
path: `${prefix}yAxis`,
|
||||
name: 'Y Buckets',
|
||||
editor: AxisEditor,
|
||||
category,
|
||||
defaultValue: {
|
||||
mode: HeatmapCalculationMode.Size,
|
||||
},
|
||||
});
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
import { FieldType } from '@grafana/data';
|
||||
import { toDataFrame } from '@grafana/data/src/dataframe/processDataFrame';
|
||||
import { calculateHeatmapFromData } from './heatmap';
|
||||
import { HeatmapCalculationOptions } from './models.gen';
|
||||
|
||||
describe('Heatmap transformer', () => {
|
||||
it('calculate heatmap from input data', async () => {
|
||||
const options: HeatmapCalculationOptions = {
|
||||
//
|
||||
};
|
||||
|
||||
const data = toDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3, 4] },
|
||||
{ name: 'temp', type: FieldType.number, values: [1.1, 2.2, 3.3, 4.4] },
|
||||
],
|
||||
});
|
||||
|
||||
const heatmap = calculateHeatmapFromData([data], options);
|
||||
|
||||
expect(heatmap).toBeDefined();
|
||||
});
|
||||
});
|
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
ArrayVector,
|
||||
DataFrame,
|
||||
DataTransformerID,
|
||||
FieldType,
|
||||
incrRoundUp,
|
||||
incrRoundDn,
|
||||
SynchronousDataTransformerInfo,
|
||||
DataFrameType,
|
||||
getFieldDisplayName,
|
||||
Field,
|
||||
} from '@grafana/data';
|
||||
import { map } from 'rxjs';
|
||||
import { HeatmapCalculationMode, HeatmapCalculationOptions } from './models.gen';
|
||||
import { niceLinearIncrs, niceTimeIncrs } from './utils';
|
||||
|
||||
export interface HeatmapTransformerOptions extends HeatmapCalculationOptions {
|
||||
/** the raw values will still exist in results after transformation */
|
||||
keepOriginalData?: boolean;
|
||||
}
|
||||
|
||||
export const heatmapTransformer: SynchronousDataTransformerInfo<HeatmapTransformerOptions> = {
|
||||
id: DataTransformerID.heatmap,
|
||||
name: 'Create heatmap',
|
||||
description: 'calculate heatmap from source data',
|
||||
defaultOptions: {},
|
||||
|
||||
operator: (options) => (source) => source.pipe(map((data) => heatmapTransformer.transformer(options)(data))),
|
||||
|
||||
transformer: (options: HeatmapTransformerOptions) => {
|
||||
return (data: DataFrame[]) => {
|
||||
const v = calculateHeatmapFromData(data, options);
|
||||
if (options.keepOriginalData) {
|
||||
return [v, ...data];
|
||||
}
|
||||
return [v];
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function sortAscStrInf(aName?: string | null, bName?: string | null) {
|
||||
let aBound = aName === '+Inf' ? Infinity : +(aName ?? 0);
|
||||
let bBound = bName === '+Inf' ? Infinity : +(bName ?? 0);
|
||||
|
||||
return aBound - bBound;
|
||||
}
|
||||
|
||||
/** Given existing buckets, create a values style frame */
|
||||
export function createHeatmapFromBuckets(frames: DataFrame[]): DataFrame {
|
||||
frames = frames.slice();
|
||||
|
||||
// sort ASC by frame.name (Prometheus bucket bound)
|
||||
// or use frame.fields[1].config.displayNameFromDS ?
|
||||
frames.sort((a, b) => sortAscStrInf(a.name, b.name));
|
||||
|
||||
const bucketBounds = frames.map((frame, i) => {
|
||||
return i; // until we have y ordinal scales working for facets/scatter
|
||||
|
||||
/*
|
||||
let bound: number;
|
||||
|
||||
if (frame.name === '+Inf') {
|
||||
// TODO: until we have labeled y, treat +Inf as previous bucket + 10%
|
||||
bound = +(frames[i - 1].name ?? 0) * 1.1;
|
||||
} else {
|
||||
bound = +(frame.name ?? 0);
|
||||
}
|
||||
|
||||
return bound;
|
||||
*/
|
||||
});
|
||||
|
||||
// assumes all Time fields are identical
|
||||
// TODO: handle null-filling w/ fields[0].config.interval?
|
||||
const xField = frames[0].fields[0];
|
||||
const xValues = xField.values.toArray();
|
||||
const yField = frames[0].fields[1];
|
||||
|
||||
// similar to initBins() below
|
||||
const len = xValues.length * bucketBounds.length;
|
||||
const xs = new Array(len);
|
||||
const ys = new Array(len);
|
||||
const counts2 = new Array(len);
|
||||
|
||||
// cumulative counts
|
||||
const counts = frames.map((frame) => frame.fields[1].values.toArray().slice());
|
||||
|
||||
// de-accumulate
|
||||
counts.reverse();
|
||||
counts.forEach((bucketCounts, bi) => {
|
||||
if (bi < counts.length - 1) {
|
||||
for (let i = 0; i < bucketCounts.length; i++) {
|
||||
bucketCounts[i] -= counts[bi + 1][i];
|
||||
}
|
||||
}
|
||||
});
|
||||
counts.reverse();
|
||||
|
||||
// transpose
|
||||
counts.forEach((bucketCounts, bi) => {
|
||||
for (let i = 0; i < bucketCounts.length; i++) {
|
||||
counts2[counts.length * i + bi] = bucketCounts[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];
|
||||
}
|
||||
|
||||
return {
|
||||
length: xs.length,
|
||||
meta: {
|
||||
type: DataFrameType.HeatmapScanlines,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'xMax',
|
||||
type: xField.type,
|
||||
values: new ArrayVector(xs),
|
||||
config: xField.config,
|
||||
},
|
||||
{
|
||||
name: 'yMax',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector(ys),
|
||||
config: yField.config,
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector(counts2),
|
||||
config: {
|
||||
unit: 'short',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCalculationOptions): DataFrame {
|
||||
//console.time('calculateHeatmapFromData');
|
||||
|
||||
let xs: number[] = [];
|
||||
let ys: number[] = [];
|
||||
|
||||
// optimization
|
||||
//let xMin = Infinity;
|
||||
//let xMax = -Infinity;
|
||||
|
||||
let xField: Field | undefined = undefined;
|
||||
let yField: Field | undefined = undefined;
|
||||
|
||||
for (let frame of frames) {
|
||||
// TODO: assumes numeric timestamps, ordered asc, without nulls
|
||||
const x = frame.fields.find((f) => f.type === FieldType.time);
|
||||
if (!x) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!xField) {
|
||||
xField = x; // the first X
|
||||
}
|
||||
|
||||
const xValues = x.values.toArray();
|
||||
for (let field of frame.fields) {
|
||||
if (field !== x && field.type === FieldType.number) {
|
||||
xs = xs.concat(xValues);
|
||||
ys = ys.concat(field.values.toArray());
|
||||
|
||||
if (!yField) {
|
||||
yField = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!xField || !yField) {
|
||||
throw 'no heatmap fields found';
|
||||
}
|
||||
|
||||
const heat2d = heatmap(xs, ys, {
|
||||
xSorted: true,
|
||||
xTime: xField.type === FieldType.time,
|
||||
xMode: options.xAxis?.mode,
|
||||
xSize: +(options.xAxis?.value ?? 0),
|
||||
yMode: options.yAxis?.mode,
|
||||
ySize: +(options.yAxis?.value ?? 0),
|
||||
});
|
||||
|
||||
const frame = {
|
||||
length: heat2d.x.length,
|
||||
name: getFieldDisplayName(yField),
|
||||
meta: {
|
||||
type: DataFrameType.HeatmapScanlines,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'xMin',
|
||||
type: xField.type,
|
||||
values: new ArrayVector(heat2d.x),
|
||||
config: xField.config,
|
||||
},
|
||||
{
|
||||
name: 'yMin',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector(heat2d.y),
|
||||
config: yField.config, // keep units from the original source
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector(heat2d.count),
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
//console.timeEnd('calculateHeatmapFromData');
|
||||
|
||||
//console.log({ tiles: frame.length });
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
interface HeatmapOpts {
|
||||
// default is 10% of data range, snapped to a "nice" increment
|
||||
xMode?: HeatmapCalculationMode;
|
||||
yMode?: HeatmapCalculationMode;
|
||||
xSize?: number;
|
||||
ySize?: number;
|
||||
|
||||
// use Math.ceil instead of Math.floor for bucketing
|
||||
xCeil?: boolean;
|
||||
yCeil?: boolean;
|
||||
|
||||
// log2 or log10 buckets
|
||||
xLog?: 2 | 10;
|
||||
yLog?: 2 | 10;
|
||||
|
||||
xTime?: boolean;
|
||||
yTime?: boolean;
|
||||
|
||||
// optimization hints for known data ranges (sorted, pre-scanned, etc)
|
||||
xMin?: number;
|
||||
xMax?: number;
|
||||
yMin?: number;
|
||||
yMax?: number;
|
||||
|
||||
xSorted?: boolean;
|
||||
ySorted?: boolean;
|
||||
}
|
||||
|
||||
// TODO: handle NaN, Inf, -Inf, null, undefined values in xs & ys
|
||||
function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) {
|
||||
let len = xs.length;
|
||||
|
||||
let xSorted = opts?.xSorted ?? false;
|
||||
let ySorted = opts?.ySorted ?? false;
|
||||
|
||||
// find x and y limits to pre-compute buckets struct
|
||||
let minX = xSorted ? xs[0] : Infinity;
|
||||
let minY = ySorted ? ys[0] : Infinity;
|
||||
let maxX = xSorted ? xs[len - 1] : -Infinity;
|
||||
let maxY = ySorted ? ys[len - 1] : -Infinity;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (!xSorted) {
|
||||
minX = Math.min(minX, xs[i]);
|
||||
maxX = Math.max(maxX, xs[i]);
|
||||
}
|
||||
|
||||
if (!ySorted) {
|
||||
minY = Math.min(minY, ys[i]);
|
||||
maxY = Math.max(maxY, ys[i]);
|
||||
}
|
||||
}
|
||||
|
||||
//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 xBinIncr = opts?.xSize ?? 0;
|
||||
let yBinIncr = opts?.ySize ?? 0;
|
||||
let xMode = opts?.xMode;
|
||||
let yMode = opts?.yMode;
|
||||
|
||||
// fall back to 10 buckets if invalid settings
|
||||
if (!Number.isFinite(xBinIncr) || xBinIncr <= 0) {
|
||||
xMode = HeatmapCalculationMode.Count;
|
||||
xBinIncr = 20;
|
||||
}
|
||||
if (!Number.isFinite(yBinIncr) || yBinIncr <= 0) {
|
||||
yMode = HeatmapCalculationMode.Count;
|
||||
yBinIncr = 10;
|
||||
}
|
||||
|
||||
if (xMode === HeatmapCalculationMode.Count) {
|
||||
// TODO: optionally use view range min/max instead of data range for bucket sizing
|
||||
let approx = (maxX - minX) / Math.max(xBinIncr - 1, 1);
|
||||
// nice-ify
|
||||
let xIncrs = opts?.xTime ? niceTimeIncrs : niceLinearIncrs;
|
||||
let xIncrIdx = xIncrs.findIndex((bucketSize) => bucketSize > approx) - 1;
|
||||
xBinIncr = xIncrs[Math.max(xIncrIdx, 0)];
|
||||
}
|
||||
|
||||
if (yMode === HeatmapCalculationMode.Count) {
|
||||
// TODO: optionally use view range min/max instead of data range for bucket sizing
|
||||
let approx = (maxY - minY) / Math.max(yBinIncr - 1, 1);
|
||||
// nice-ify
|
||||
let yIncrs = opts?.yTime ? niceTimeIncrs : niceLinearIncrs;
|
||||
let yIncrIdx = yIncrs.findIndex((bucketSize) => bucketSize > approx) - 1;
|
||||
yBinIncr = yIncrs[Math.max(yIncrIdx, 0)];
|
||||
}
|
||||
|
||||
// console.log({
|
||||
// yBinIncr,
|
||||
// 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 minXBin = binX(minX);
|
||||
let maxXBin = binX(maxX);
|
||||
let minYBin = binY(minY);
|
||||
let maxYBin = binY(maxY);
|
||||
|
||||
let xBinQty = Math.round((maxXBin - minXBin) / xBinIncr) + 1;
|
||||
let yBinQty = Math.round((maxYBin - minYBin) / yBinIncr) + 1;
|
||||
|
||||
let [xs2, ys2, counts] = initBins(xBinQty, yBinQty, minXBin, xBinIncr, minYBin, yBinIncr);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const xi = (binX(xs[i]) - minXBin) / xBinIncr;
|
||||
const yi = (binY(ys[i]) - minYBin) / yBinIncr;
|
||||
const ci = xi * yBinQty + yi;
|
||||
|
||||
counts[ci]++;
|
||||
}
|
||||
|
||||
return {
|
||||
x: xs2,
|
||||
y: ys2,
|
||||
count: counts,
|
||||
};
|
||||
}
|
||||
|
||||
function initBins(xQty: number, yQty: number, xMin: number, xIncr: number, yMin: number, yIncr: number) {
|
||||
const len = xQty * yQty;
|
||||
const xs = new Array<number>(len);
|
||||
const ys = new Array<number>(len);
|
||||
const counts = new Array<number>(len);
|
||||
|
||||
for (let i = 0, yi = 0, x = xMin; i < len; yi = ++i % yQty) {
|
||||
counts[i] = 0;
|
||||
ys[i] = yMin + yi * yIncr;
|
||||
|
||||
if (yi === 0 && i >= yQty) {
|
||||
x += xIncr;
|
||||
}
|
||||
|
||||
xs[i] = x;
|
||||
}
|
||||
|
||||
return [xs, ys, counts];
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
import { DataFrameType } from '@grafana/data';
|
||||
|
||||
export enum HeatmapCalculationMode {
|
||||
Size = 'size',
|
||||
Count = 'count',
|
||||
}
|
||||
|
||||
export interface HeatmapCalculationAxisConfig {
|
||||
mode?: HeatmapCalculationMode;
|
||||
value?: string; // number or interval string ie 10s
|
||||
}
|
||||
|
||||
export interface HeatmapCalculationOptions {
|
||||
xAxis?: HeatmapCalculationAxisConfig;
|
||||
yAxis?: HeatmapCalculationAxisConfig;
|
||||
xAxisField?: string; // name of the x field
|
||||
encoding?: DataFrameType.HeatmapBuckets | DataFrameType.HeatmapScanlines;
|
||||
}
|
@@ -0,0 +1,125 @@
|
||||
const { abs, round, pow } = Math;
|
||||
|
||||
export function roundDec(val: number, dec: number) {
|
||||
return round(val * (dec = 10 ** dec)) / dec;
|
||||
}
|
||||
|
||||
export const fixedDec = new Map();
|
||||
|
||||
export function guessDec(num: number) {
|
||||
return (('' + num).split('.')[1] || '').length;
|
||||
}
|
||||
|
||||
export function genIncrs(base: number, minExp: number, maxExp: number, mults: number[]) {
|
||||
let incrs = [];
|
||||
|
||||
let multDec = mults.map(guessDec);
|
||||
|
||||
for (let exp = minExp; exp < maxExp; exp++) {
|
||||
let expa = abs(exp);
|
||||
let mag = roundDec(pow(base, exp), expa);
|
||||
|
||||
for (let i = 0; i < mults.length; i++) {
|
||||
let _incr = mults[i] * mag;
|
||||
let dec = (_incr >= 0 && exp >= 0 ? 0 : expa) + (exp >= multDec[i] ? 0 : multDec[i]);
|
||||
let incr = roundDec(_incr, dec);
|
||||
incrs.push(incr);
|
||||
fixedDec.set(incr, dec);
|
||||
}
|
||||
}
|
||||
|
||||
return incrs;
|
||||
}
|
||||
|
||||
const onlyWhole = (v: number) => v % 1 === 0;
|
||||
|
||||
const allMults = [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5];
|
||||
|
||||
// ...0.01, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.2, 0.25, 0.3, 0.4, 0.5...
|
||||
export const decIncrs = genIncrs(10, -16, 0, allMults);
|
||||
|
||||
// 1, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10, 20, 25, 30, 40, 50...
|
||||
export const oneIncrs = genIncrs(10, 0, 16, allMults);
|
||||
|
||||
// 1, 2, 3, 4, 5, 10, 20, 25, 50...
|
||||
export const wholeIncrs = oneIncrs.filter(onlyWhole);
|
||||
|
||||
export const numIncrs = decIncrs.concat(oneIncrs);
|
||||
|
||||
export const niceLinearIncrs = decIncrs.concat(wholeIncrs);
|
||||
|
||||
const sec = 1 * 1e3;
|
||||
const min = 60 * sec;
|
||||
const hour = 60 * min;
|
||||
const day = 24 * hour;
|
||||
const year = 365 * day;
|
||||
|
||||
// in milliseconds
|
||||
export const niceTimeIncrs = [
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
10,
|
||||
20,
|
||||
25,
|
||||
40,
|
||||
50,
|
||||
100,
|
||||
200,
|
||||
250,
|
||||
400,
|
||||
500,
|
||||
|
||||
sec,
|
||||
2 * sec,
|
||||
4 * sec,
|
||||
5 * sec,
|
||||
10 * sec,
|
||||
15 * sec,
|
||||
20 * sec,
|
||||
30 * sec,
|
||||
|
||||
min,
|
||||
2 * min,
|
||||
4 * min,
|
||||
5 * min,
|
||||
10 * min,
|
||||
15 * min,
|
||||
20 * min,
|
||||
30 * min,
|
||||
|
||||
hour,
|
||||
2 * hour,
|
||||
4 * hour,
|
||||
6 * hour,
|
||||
8 * hour,
|
||||
12 * hour,
|
||||
18 * hour,
|
||||
|
||||
day,
|
||||
2 * day,
|
||||
3 * day,
|
||||
4 * day,
|
||||
5 * day,
|
||||
6 * day,
|
||||
7 * day,
|
||||
10 * day,
|
||||
15 * day,
|
||||
30 * day,
|
||||
45 * day,
|
||||
60 * day,
|
||||
90 * day,
|
||||
180 * day,
|
||||
|
||||
year,
|
||||
2 * year,
|
||||
3 * year,
|
||||
4 * year,
|
||||
5 * year,
|
||||
6 * year,
|
||||
7 * year,
|
||||
8 * year,
|
||||
9 * year,
|
||||
10 * year,
|
||||
];
|
@@ -5,10 +5,9 @@ import { NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
|
||||
import { set, get as lodashGet } from 'lodash';
|
||||
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
|
||||
import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
|
||||
import { SpatialTransformOptions } from './models.gen';
|
||||
|
||||
export function getTransformerOptionPane<T = any>(
|
||||
props: TransformerUIProps<SpatialTransformOptions>,
|
||||
props: TransformerUIProps<T>,
|
||||
supplier: PanelOptionsSupplier<T>
|
||||
): OptionsPaneCategoryDescriptor {
|
||||
const context: StandardEditorContext<unknown, unknown> = {
|
||||
|
@@ -20,6 +20,7 @@ import { prepareTimeseriesTransformerRegistryItem } from '../components/Transfor
|
||||
import { convertFieldTypeTransformRegistryItem } from '../components/TransformersUI/ConvertFieldTypeTransformerEditor';
|
||||
import { fieldLookupTransformRegistryItem } from '../components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor';
|
||||
import { extractFieldsTransformRegistryItem } from '../components/TransformersUI/extractFields/ExtractFieldsTransformerEditor';
|
||||
import { heatmapTransformRegistryItem } from '../components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor';
|
||||
import { spatialTransformRegistryItem } from '../components/TransformersUI/spatial/SpatialTransformerEditor';
|
||||
|
||||
export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> => {
|
||||
@@ -46,5 +47,6 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> =
|
||||
spatialTransformRegistryItem,
|
||||
fieldLookupTransformRegistryItem,
|
||||
extractFieldsTransformRegistryItem,
|
||||
heatmapTransformRegistryItem,
|
||||
];
|
||||
};
|
||||
|
@@ -50,6 +50,7 @@ import * as dashListPanel from 'app/plugins/panel/dashlist/module';
|
||||
import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module';
|
||||
import * as alertListPanel from 'app/plugins/panel/alertlist/module';
|
||||
import * as annoListPanel from 'app/plugins/panel/annolist/module';
|
||||
import * as heatmapPanelNG from 'app/plugins/panel/heatmap-new/module';
|
||||
import * as tablePanel from 'app/plugins/panel/table/module';
|
||||
import * as statPanel from 'app/plugins/panel/stat/module';
|
||||
import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
|
||||
@@ -113,6 +114,7 @@ const builtInPlugins: any = {
|
||||
'app/plugins/panel/alertlist/module': alertListPanel,
|
||||
'app/plugins/panel/annolist/module': annoListPanel,
|
||||
'app/plugins/panel/heatmap/module': heatmapPanel,
|
||||
'app/plugins/panel/heatmap-new/module': heatmapPanelNG,
|
||||
'app/plugins/panel/table/module': tablePanel,
|
||||
'app/plugins/panel/table-old/module': tableOldPanel,
|
||||
'app/plugins/panel/news/module': newsPanel,
|
||||
|
194
public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx
Normal file
194
public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Field, FieldType, formattedValueToString, LinkModel } from '@grafana/data';
|
||||
|
||||
import { HeatmapHoverEvent } from './utils';
|
||||
import { BucketLayout, HeatmapData } from './fields';
|
||||
import { LinkButton, VerticalGroup } from '@grafana/ui';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
|
||||
type Props = {
|
||||
data: HeatmapData;
|
||||
hover: HeatmapHoverEvent;
|
||||
showHistogram?: boolean;
|
||||
};
|
||||
|
||||
export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
|
||||
const xField = data.heatmap?.fields[0];
|
||||
const yField = data.heatmap?.fields[1];
|
||||
const countField = data.heatmap?.fields[2];
|
||||
|
||||
const xDisp = (v: any) => {
|
||||
if (xField?.display) {
|
||||
return formattedValueToString(xField.display(v));
|
||||
}
|
||||
if (xField?.type === FieldType.time) {
|
||||
const tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss';
|
||||
const dashboard = getDashboardSrv().getCurrent();
|
||||
return dashboard?.formatDate(v, tooltipTimeFormat);
|
||||
}
|
||||
return `${v}XX`;
|
||||
};
|
||||
|
||||
const xVals = xField?.values.toArray();
|
||||
const yVals = yField?.values.toArray();
|
||||
const countVals = countField?.values.toArray();
|
||||
|
||||
let yDispSrc, yDisp;
|
||||
|
||||
// labeled buckets
|
||||
if (data.yAxisValues) {
|
||||
yDispSrc = data.yAxisValues;
|
||||
yDisp = (v: any) => v;
|
||||
} else {
|
||||
yDispSrc = yVals;
|
||||
yDisp = (v: any) => {
|
||||
if (yField?.display) {
|
||||
return formattedValueToString(yField.display(v));
|
||||
}
|
||||
return `${v}YYY`;
|
||||
};
|
||||
}
|
||||
|
||||
const yValueIdx = hover.index % data.yBucketCount! ?? 0;
|
||||
|
||||
const yMinIdx = data.yLayout === BucketLayout.le ? yValueIdx - 1 : yValueIdx;
|
||||
const yMaxIdx = data.yLayout === BucketLayout.le ? yValueIdx : yValueIdx + 1;
|
||||
|
||||
const yBucketMin = yDispSrc?.[yMinIdx];
|
||||
const yBucketMax = yDispSrc?.[yMaxIdx];
|
||||
|
||||
const xBucketMin = xVals?.[hover.index];
|
||||
const xBucketMax = xBucketMin + data.xBucketSize;
|
||||
|
||||
const count = countVals?.[hover.index];
|
||||
|
||||
const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip));
|
||||
const links: Array<LinkModel<Field>> = [];
|
||||
const linkLookup = new Set<string>();
|
||||
|
||||
for (const field of visibleFields ?? []) {
|
||||
// TODO: Currently always undefined? (getLinks)
|
||||
if (field.getLinks) {
|
||||
const v = field.values.get(hover.index);
|
||||
const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v };
|
||||
|
||||
field.getLinks({ calculatedValue: disp, valueRowIndex: hover.index }).forEach((link) => {
|
||||
const key = `${link.title}/${link.href}`;
|
||||
if (!linkLookup.has(key)) {
|
||||
links.push(link);
|
||||
linkLookup.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let can = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
let histCssWidth = 150;
|
||||
let histCssHeight = 50;
|
||||
let histCanWidth = Math.round(histCssWidth * devicePixelRatio);
|
||||
let histCanHeight = Math.round(histCssHeight * devicePixelRatio);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (showHistogram) {
|
||||
let histCtx = can.current?.getContext('2d');
|
||||
|
||||
if (histCtx && xVals && yVals && countVals) {
|
||||
let fromIdx = hover.index;
|
||||
|
||||
while (xVals[fromIdx--] === xVals[hover.index]) {}
|
||||
|
||||
fromIdx++;
|
||||
|
||||
let toIdx = fromIdx + data.yBucketCount!;
|
||||
|
||||
let maxCount = 0;
|
||||
|
||||
let i = fromIdx;
|
||||
while (i < toIdx) {
|
||||
let c = countVals[i];
|
||||
maxCount = Math.max(maxCount, c);
|
||||
i++;
|
||||
}
|
||||
|
||||
let pHov = new Path2D();
|
||||
let pRest = new Path2D();
|
||||
|
||||
i = fromIdx;
|
||||
let j = 0;
|
||||
while (i < toIdx) {
|
||||
let c = countVals[i];
|
||||
|
||||
if (c > 0) {
|
||||
let pctY = c / maxCount;
|
||||
let pctX = j / (data.yBucketCount! + 1);
|
||||
|
||||
let p = i === hover.index ? pHov : pRest;
|
||||
|
||||
p.rect(
|
||||
Math.round(histCanWidth * pctX),
|
||||
Math.round(histCanHeight * (1 - pctY)),
|
||||
Math.round(histCanWidth / data.yBucketCount!),
|
||||
Math.round(histCanHeight * pctY)
|
||||
);
|
||||
}
|
||||
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
|
||||
histCtx.clearRect(0, 0, histCanWidth, histCanHeight);
|
||||
|
||||
histCtx.fillStyle = '#ffffff80';
|
||||
histCtx.fill(pRest);
|
||||
|
||||
histCtx.fillStyle = '#ff000080';
|
||||
histCtx.fill(pHov);
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[hover.index]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div>{xDisp(xBucketMin)}</div>
|
||||
<div>{xDisp(xBucketMax)}</div>
|
||||
</div>
|
||||
{showHistogram && (
|
||||
<canvas
|
||||
width={histCanWidth}
|
||||
height={histCanHeight}
|
||||
ref={can}
|
||||
style={{ width: histCanWidth + 'px', height: histCanHeight + 'px' }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div>
|
||||
Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)}
|
||||
</div>
|
||||
<div>Count: {count}</div>
|
||||
</div>
|
||||
{links.length > 0 && (
|
||||
<VerticalGroup>
|
||||
{links.map((link, i) => (
|
||||
<LinkButton
|
||||
key={i}
|
||||
icon={'external-link-alt'}
|
||||
target={link.target}
|
||||
href={link.href}
|
||||
onClick={link.onClick}
|
||||
fill="text"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{link.title}
|
||||
</LinkButton>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
125
public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx
Normal file
125
public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2, PanelProps } from '@grafana/data';
|
||||
import { Portal, UPlotChart, useStyles2, useTheme2, VizLayout, VizTooltipContainer } from '@grafana/ui';
|
||||
import { PanelDataErrorView } from '@grafana/runtime';
|
||||
|
||||
import { HeatmapData, prepareHeatmapData } from './fields';
|
||||
import { PanelOptions } from './models.gen';
|
||||
import { quantizeScheme } from './palettes';
|
||||
import { HeatmapHoverEvent, prepConfig } from './utils';
|
||||
import { HeatmapHoverView } from './HeatmapHoverView';
|
||||
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
|
||||
|
||||
interface HeatmapPanelProps extends PanelProps<PanelOptions> {}
|
||||
|
||||
export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
|
||||
data,
|
||||
id,
|
||||
timeRange,
|
||||
timeZone,
|
||||
width,
|
||||
height,
|
||||
options,
|
||||
fieldConfig,
|
||||
onChangeTimeRange,
|
||||
replaceVariables,
|
||||
}) => {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const info = useMemo(() => prepareHeatmapData(data.series, options, theme), [data, options, theme]);
|
||||
|
||||
const facets = useMemo(() => [null, info.heatmap?.fields.map((f) => f.values.toArray())], [info.heatmap]);
|
||||
|
||||
//console.log(facets);
|
||||
|
||||
const palette = useMemo(() => quantizeScheme(options.color, theme), [options.color, theme]);
|
||||
|
||||
const [hover, setHover] = useState<HeatmapHoverEvent | undefined>(undefined);
|
||||
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
|
||||
const isToolTipOpen = useRef<boolean>(false);
|
||||
|
||||
const onCloseToolTip = () => {
|
||||
isToolTipOpen.current = false;
|
||||
setShouldDisplayCloseButton(false);
|
||||
onhover(null);
|
||||
};
|
||||
|
||||
const onclick = () => {
|
||||
isToolTipOpen.current = !isToolTipOpen.current;
|
||||
|
||||
// Linking into useState required to re-render tooltip
|
||||
setShouldDisplayCloseButton(isToolTipOpen.current);
|
||||
};
|
||||
|
||||
const onhover = useCallback(
|
||||
(evt?: HeatmapHoverEvent | null) => {
|
||||
setHover(evt ?? undefined);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[options, data.structureRev]
|
||||
);
|
||||
|
||||
const dataRef = useRef<HeatmapData>(info);
|
||||
|
||||
dataRef.current = info;
|
||||
|
||||
const builder = useMemo(() => {
|
||||
return prepConfig({
|
||||
dataRef,
|
||||
theme,
|
||||
onhover: options.tooltip.show ? onhover : () => {},
|
||||
onclick: options.tooltip.show ? onclick : () => {},
|
||||
isToolTipOpen,
|
||||
timeZone,
|
||||
timeRange,
|
||||
palette,
|
||||
cellGap: options.cellGap,
|
||||
hideThreshold: options.hideThreshold,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options, data.structureRev]);
|
||||
|
||||
if (info.warning || !info.heatmap) {
|
||||
return <PanelDataErrorView panelId={id} data={data} needsNumberField={true} message={info.warning} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VizLayout width={width} height={height}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
// <pre style={{ width: vizWidth, height: vizHeight, border: '1px solid green', margin: '0px' }}>
|
||||
// {JSON.stringify(scatterData, null, 2)}
|
||||
// </pre>
|
||||
<UPlotChart config={builder} data={facets as any} width={vizWidth} height={vizHeight} timeRange={timeRange}>
|
||||
{/*children ? children(config, alignedFrame) : null*/}
|
||||
</UPlotChart>
|
||||
)}
|
||||
</VizLayout>
|
||||
<Portal>
|
||||
{hover && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: hover.pageX, y: hover.pageY }}
|
||||
offset={{ x: 10, y: 10 }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{shouldDisplayCloseButton && (
|
||||
<>
|
||||
<CloseButton onClick={onCloseToolTip} />
|
||||
<div className={styles.closeButtonSpacer} />
|
||||
</>
|
||||
)}
|
||||
<HeatmapHoverView data={info} hover={hover} showHistogram={options.tooltip.yHistogram} />
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
closeButtonSpacer: css`
|
||||
margin-bottom: 15px;
|
||||
`,
|
||||
});
|
7
public/app/plugins/panel/heatmap-new/README.md
Normal file
7
public/app/plugins/panel/heatmap-new/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Heatmap Panel (NEXT GEN) - Native Plugin
|
||||
|
||||
The Heatmap panel allows you to view histograms over time and is **included** with Grafana.
|
||||
|
||||
Read more about it here:
|
||||
|
||||
[http://docs.grafana.org/features/panels/heatmap/](http://docs.grafana.org/features/panels/heatmap/)
|
13
public/app/plugins/panel/heatmap-new/fields.test.ts
Normal file
13
public/app/plugins/panel/heatmap-new/fields.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createTheme } from '@grafana/data';
|
||||
import { PanelOptions } from './models.gen';
|
||||
|
||||
const theme = createTheme();
|
||||
|
||||
describe('Heatmap data', () => {
|
||||
const options: PanelOptions = {} as PanelOptions;
|
||||
|
||||
it('simple test stub', () => {
|
||||
expect(theme).toBeDefined();
|
||||
expect(options).toBeDefined();
|
||||
});
|
||||
});
|
115
public/app/plugins/panel/heatmap-new/fields.ts
Normal file
115
public/app/plugins/panel/heatmap-new/fields.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { DataFrame, DataFrameType, getDisplayProcessor, GrafanaTheme2 } from '@grafana/data';
|
||||
import {
|
||||
calculateHeatmapFromData,
|
||||
createHeatmapFromBuckets,
|
||||
sortAscStrInf,
|
||||
} from 'app/core/components/TransformersUI/calculateHeatmap/heatmap';
|
||||
import { HeatmapSourceMode, PanelOptions } from './models.gen';
|
||||
|
||||
export const enum BucketLayout {
|
||||
le = 'le',
|
||||
ge = 'ge',
|
||||
}
|
||||
|
||||
export interface HeatmapData {
|
||||
// List of heatmap frames
|
||||
heatmap?: DataFrame;
|
||||
|
||||
yAxisValues?: Array<number | string | null>;
|
||||
|
||||
xBucketSize?: number;
|
||||
yBucketSize?: number;
|
||||
|
||||
xBucketCount?: number;
|
||||
yBucketCount?: number;
|
||||
|
||||
xLayout?: BucketLayout;
|
||||
yLayout?: BucketLayout;
|
||||
|
||||
// Errors
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export function prepareHeatmapData(
|
||||
frames: DataFrame[] | undefined,
|
||||
options: PanelOptions,
|
||||
theme: GrafanaTheme2
|
||||
): HeatmapData {
|
||||
if (!frames?.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { source } = options;
|
||||
if (source === HeatmapSourceMode.Calculate) {
|
||||
// TODO, check for error etc
|
||||
return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), theme);
|
||||
}
|
||||
|
||||
// Find a well defined heatmap
|
||||
let heatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapScanlines);
|
||||
if (heatmap) {
|
||||
return getHeatmapData(heatmap, theme);
|
||||
}
|
||||
|
||||
if (source === HeatmapSourceMode.Data) {
|
||||
// TODO: check for names xMin, yMin etc...
|
||||
return getHeatmapData(createHeatmapFromBuckets(frames), theme);
|
||||
}
|
||||
|
||||
// detect a frame-per-bucket heatmap frame
|
||||
// TODO: improve heuristic? infer from fields[1].labels.le === '+Inf' ?
|
||||
if (frames[0].meta?.custom?.resultType === 'matrix' && frames.some((f) => f.name?.startsWith('+Inf'))) {
|
||||
return {
|
||||
yAxisValues: frames.map((f) => f.name ?? null).sort(sortAscStrInf),
|
||||
...getHeatmapData(createHeatmapFromBuckets(frames), theme),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO, check for error etc
|
||||
return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), theme);
|
||||
}
|
||||
|
||||
const getHeatmapData = (frame: DataFrame, theme: GrafanaTheme2): HeatmapData => {
|
||||
if (frame.meta?.type !== DataFrameType.HeatmapScanlines) {
|
||||
return {
|
||||
warning: 'Expected heatmap scanlines format',
|
||||
heatmap: frame,
|
||||
};
|
||||
}
|
||||
|
||||
if (frame.fields.length < 2 || frame.length < 2) {
|
||||
return { heatmap: frame };
|
||||
}
|
||||
|
||||
if (!frame.fields[1].display) {
|
||||
frame.fields[1].display = getDisplayProcessor({ field: frame.fields[1], theme });
|
||||
}
|
||||
|
||||
// infer bucket sizes from data (for now)
|
||||
// the 'heatmap-scanlines' dense frame format looks like:
|
||||
// x: 1,1,1,1,2,2,2,2
|
||||
// y: 3,4,5,6,3,4,5,6
|
||||
// count: 0,0,0,7,0,3,0,1
|
||||
|
||||
const xs = frame.fields[0].values.toArray();
|
||||
const ys = frame.fields[1].values.toArray();
|
||||
const dlen = xs.length;
|
||||
|
||||
// below is literally copy/paste from the pathBuilder code in utils.ts
|
||||
// detect x and y bin qtys by detecting layout repetition in x & y data
|
||||
let yBinQty = dlen - ys.lastIndexOf(ys[0]);
|
||||
let xBinQty = dlen / yBinQty;
|
||||
let yBinIncr = ys[1] - ys[0];
|
||||
let xBinIncr = xs[yBinQty] - xs[0];
|
||||
|
||||
return {
|
||||
heatmap: frame,
|
||||
xBucketSize: xBinIncr,
|
||||
yBucketSize: yBinIncr,
|
||||
xBucketCount: xBinQty,
|
||||
yBucketCount: yBinQty,
|
||||
// TODO: improve heuristic
|
||||
xLayout: frame.fields[0].name === 'xMax' ? BucketLayout.le : BucketLayout.ge,
|
||||
yLayout: frame.fields[1].name === 'yMax' ? BucketLayout.le : BucketLayout.ge,
|
||||
};
|
||||
};
|
1
public/app/plugins/panel/heatmap-new/img/heatmap.svg
Normal file
1
public/app/plugins/panel/heatmap-new/img/heatmap.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 80.09 80.09"><defs><style>.cls-1{fill:#3865ab;}.cls-2{fill:#84aff1;}.cls-3{fill:url(#linear-gradient);}.cls-4{fill:url(#linear-gradient-2);}</style><linearGradient id="linear-gradient" y1="19.02" x2="66.08" y2="19.02" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient><linearGradient id="linear-gradient-2" y1="54.06" x2="66.08" y2="54.06" xlink:href="#linear-gradient"/></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect class="cls-1" width="10.02" height="10.02" rx="1"/><rect class="cls-2" x="28.03" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="42.05" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="56.06" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="70.08" y="14.02" width="10.02" height="10.02" rx="1"/><rect class="cls-2" x="28.03" y="28.03" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="42.05" y="28.03" width="10.02" height="10.02" rx="1"/><rect class="cls-2" x="56.06" y="28.03" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="70.08" y="28.03" width="10.02" height="10.02" rx="1"/><rect class="cls-1" y="42.05" width="10.02" height="10.02" rx="1"/><rect class="cls-2" x="42.05" y="42.05" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="56.06" y="42.05" width="10.02" height="10.02" rx="1"/><rect class="cls-2" x="70.08" y="42.05" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="14.02" y="56.06" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="14.02" y="70.08" width="10.02" height="10.02" rx="1"/><rect class="cls-2" x="28.03" y="70.08" width="10.02" height="10.02" rx="1"/><rect class="cls-2" x="42.05" y="70.08" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="14.02" width="10.02" height="10.02" rx="1"/><rect class="cls-2" y="28.03" width="10.02" height="10.02" rx="1" transform="translate(38.05 28.03) rotate(90)"/><rect class="cls-1" x="70.08" y="56.06" width="10.02" height="10.02" rx="1" transform="translate(136.16 -14.02) rotate(90)"/><rect class="cls-1" x="70.08" width="10.02" height="10.02" rx="1" transform="translate(80.09 -70.08) rotate(90)"/><path class="cls-3" d="M9,24H1a1,1,0,0,1-1-1V15a1,1,0,0,1,1-1H9a1,1,0,0,1,1,1v8A1,1,0,0,1,9,24Zm15-1V15a1,1,0,0,0-1-1H15a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,24,23Zm14,0V15a1,1,0,0,0-1-1H29a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,38.05,23Zm28,0V15a1,1,0,0,0-1-1h-8a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,66.08,23Zm-15,1h-8a1,1,0,0,1-1-1V15a1,1,0,0,1,1-1h8a1,1,0,0,1,1,1v8A1,1,0,0,1,51.06,24Z"/><rect class="cls-1" x="14.02" y="28.03" width="10.02" height="10.02" rx="1"/><rect class="cls-2" x="28.03" y="56.06" width="10.02" height="10.02" rx="1"/><path class="cls-4" d="M37.05,52.06H29a1,1,0,0,1-1-1v-8a1,1,0,0,1,1-1h8a1,1,0,0,1,1,1v8A1,1,0,0,1,37.05,52.06Zm-14,0H15a1,1,0,0,1-1-1v-8a1,1,0,0,1,1-1h8a1,1,0,0,1,1,1v8A1,1,0,0,1,23,52.06Zm-13,13v-8a1,1,0,0,0-1-1H1a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1H9A1,1,0,0,0,10,65.08Zm42,0v-8a1,1,0,0,0-1-1h-8a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,52.06,65.08Zm14,0v-8a1,1,0,0,0-1-1h-8a1,1,0,0,0-1,1v8a1,1,0,0,0,1,1h8A1,1,0,0,0,66.08,65.08Z"/><rect class="cls-1" y="70.08" width="10.02" height="10.02" rx="1"/><rect class="cls-1" x="56.06" y="70.08" width="10.02" height="10.02" rx="1"/><rect class="cls-2" x="70.08" y="70.08" width="10.02" height="10.02" rx="1"/></g></g></svg>
|
After Width: | Height: | Size: 3.4 KiB |
135
public/app/plugins/panel/heatmap-new/migrations.test.ts
Normal file
135
public/app/plugins/panel/heatmap-new/migrations.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { PanelModel, FieldConfigSource } from '@grafana/data';
|
||||
import { heatmapChangedHandler } from './migrations';
|
||||
|
||||
describe('Heatmap Migrations', () => {
|
||||
let prevFieldConfig: FieldConfigSource;
|
||||
|
||||
beforeEach(() => {
|
||||
prevFieldConfig = {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
};
|
||||
});
|
||||
|
||||
it('simple heatmap', () => {
|
||||
const old: any = {
|
||||
angular: oldHeatmap,
|
||||
};
|
||||
const panel = {} as PanelModel;
|
||||
panel.options = heatmapChangedHandler(panel, 'heatmap', old, prevFieldConfig);
|
||||
expect(panel).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"fieldConfig": Object {
|
||||
"defaults": Object {},
|
||||
"overrides": Array [],
|
||||
},
|
||||
"options": Object {
|
||||
"cellGap": 2,
|
||||
"cellSize": 10,
|
||||
"color": Object {
|
||||
"exponent": 0.5,
|
||||
"fill": "dark-orange",
|
||||
"mode": "scheme",
|
||||
"scale": "exponential",
|
||||
"scheme": "Oranges",
|
||||
"steps": 256,
|
||||
},
|
||||
"heatmap": Object {
|
||||
"xAxis": Object {
|
||||
"mode": "count",
|
||||
"value": "100",
|
||||
},
|
||||
"yAxis": Object {
|
||||
"mode": "count",
|
||||
"value": "20",
|
||||
},
|
||||
},
|
||||
"legend": Object {
|
||||
"calcs": Array [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
},
|
||||
"showValue": "never",
|
||||
"source": "calculate",
|
||||
"tooltip": Object {
|
||||
"show": true,
|
||||
"yHistogram": true,
|
||||
},
|
||||
"yAxisLabels": "auto",
|
||||
"yAxisReverse": false,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
const oldHeatmap = {
|
||||
id: 4,
|
||||
gridPos: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 12,
|
||||
h: 8,
|
||||
},
|
||||
type: 'heatmap',
|
||||
title: 'Panel Title',
|
||||
datasource: {
|
||||
uid: '000000051',
|
||||
type: 'testdata',
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
scenarioId: 'random_walk',
|
||||
refId: 'A',
|
||||
datasource: {
|
||||
uid: '000000051',
|
||||
type: 'testdata',
|
||||
},
|
||||
startValue: 0,
|
||||
seriesCount: 5,
|
||||
spread: 10,
|
||||
},
|
||||
],
|
||||
heatmap: {},
|
||||
cards: {
|
||||
cardPadding: 2,
|
||||
cardRound: 10,
|
||||
},
|
||||
color: {
|
||||
mode: 'spectrum',
|
||||
cardColor: '#b4ff00',
|
||||
colorScale: 'sqrt',
|
||||
exponent: 0.5,
|
||||
colorScheme: 'interpolateBuGn',
|
||||
min: null,
|
||||
max: null,
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
},
|
||||
dataFormat: 'timeseries',
|
||||
yBucketBound: 'auto',
|
||||
reverseYBuckets: false,
|
||||
xAxis: {
|
||||
show: true,
|
||||
},
|
||||
yAxis: {
|
||||
show: true,
|
||||
format: 'short',
|
||||
decimals: null,
|
||||
logBase: 1,
|
||||
splitFactor: null,
|
||||
min: null,
|
||||
max: null,
|
||||
},
|
||||
xBucketSize: null,
|
||||
xBucketNumber: 100,
|
||||
yBucketSize: null,
|
||||
yBucketNumber: 20,
|
||||
tooltip: {
|
||||
show: true,
|
||||
showHistogram: true,
|
||||
},
|
||||
highlightCards: true,
|
||||
hideZeroBuckets: true,
|
||||
};
|
83
public/app/plugins/panel/heatmap-new/migrations.ts
Normal file
83
public/app/plugins/panel/heatmap-new/migrations.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { FieldConfigSource, PanelModel, PanelTypeChangedHandler } from '@grafana/data';
|
||||
import { LegendDisplayMode, VisibilityMode } from '@grafana/schema';
|
||||
import {
|
||||
HeatmapCalculationMode,
|
||||
HeatmapCalculationOptions,
|
||||
} from 'app/core/components/TransformersUI/calculateHeatmap/models.gen';
|
||||
import { HeatmapSourceMode, PanelOptions, defaultPanelOptions } from './models.gen';
|
||||
|
||||
/**
|
||||
* This is called when the panel changes from another panel
|
||||
*/
|
||||
export const heatmapChangedHandler: PanelTypeChangedHandler = (panel, prevPluginId, prevOptions, prevFieldConfig) => {
|
||||
if (prevPluginId === 'heatmap' && prevOptions.angular) {
|
||||
const { fieldConfig, options } = angularToReactHeatmap({
|
||||
...prevOptions.angular,
|
||||
fieldConfig: prevFieldConfig,
|
||||
});
|
||||
panel.fieldConfig = fieldConfig; // Mutates the incoming panel
|
||||
return options;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigSource; options: PanelOptions } {
|
||||
const fieldConfig: FieldConfigSource = {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
const source = angular.dataFormat === 'tsbuckets' ? HeatmapSourceMode.Data : HeatmapSourceMode.Calculate;
|
||||
const heatmap: HeatmapCalculationOptions = {
|
||||
...defaultPanelOptions.heatmap,
|
||||
};
|
||||
|
||||
if (source === HeatmapSourceMode.Calculate) {
|
||||
if (angular.xBucketSize) {
|
||||
heatmap.xAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.xBucketSize}` };
|
||||
} else if (angular.xBucketNumber) {
|
||||
heatmap.xAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.xBucketNumber}` };
|
||||
}
|
||||
|
||||
if (angular.yBucketSize) {
|
||||
heatmap.yAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.yBucketSize}` };
|
||||
} else if (angular.xBucketNumber) {
|
||||
heatmap.yAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.yBucketNumber}` };
|
||||
}
|
||||
}
|
||||
|
||||
const options: PanelOptions = {
|
||||
source,
|
||||
heatmap,
|
||||
color: {
|
||||
...defaultPanelOptions.color,
|
||||
steps: 256, // best match with existing colors
|
||||
},
|
||||
cellGap: asNumber(angular.cards?.cardPadding),
|
||||
cellSize: asNumber(angular.cards?.cardRound),
|
||||
yAxisLabels: angular.yBucketBound,
|
||||
yAxisReverse: angular.reverseYBuckets,
|
||||
legend: {
|
||||
displayMode: angular.legend.show ? LegendDisplayMode.List : LegendDisplayMode.Hidden,
|
||||
calcs: [],
|
||||
placement: 'bottom',
|
||||
},
|
||||
showValue: VisibilityMode.Never,
|
||||
tooltip: {
|
||||
show: Boolean(angular.tooltip?.show),
|
||||
yHistogram: Boolean(angular.tooltip?.showHistogram),
|
||||
},
|
||||
};
|
||||
|
||||
return { fieldConfig, options };
|
||||
}
|
||||
|
||||
function asNumber(v: any): number | undefined {
|
||||
const num = +v;
|
||||
return isNaN(num) ? undefined : num;
|
||||
}
|
||||
|
||||
export const heatmapMigrationHandler = (panel: PanelModel): Partial<PanelOptions> => {
|
||||
// Nothing yet
|
||||
return panel.options;
|
||||
};
|
33
public/app/plugins/panel/heatmap-new/models.cue
Normal file
33
public/app/plugins/panel/heatmap-new/models.cue
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright 2021 Grafana Labs
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package grafanaschema
|
||||
|
||||
Panel: {
|
||||
lineages: [
|
||||
[
|
||||
{
|
||||
PanelOptions: {
|
||||
// anything for now
|
||||
...
|
||||
}
|
||||
PanelFieldConfig: {
|
||||
// anything for now
|
||||
...
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
migrations: []
|
||||
}
|
92
public/app/plugins/panel/heatmap-new/models.gen.ts
Normal file
92
public/app/plugins/panel/heatmap-new/models.gen.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// NOTE: This file will be auto generated from models.cue
|
||||
// It is currenty hand written but will serve as the target for cuetsy
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
import { HideableFieldConfig, LegendDisplayMode, OptionsWithLegend, VisibilityMode } from '@grafana/schema';
|
||||
import { HeatmapCalculationOptions } from 'app/core/components/TransformersUI/calculateHeatmap/models.gen';
|
||||
|
||||
export const modelVersion = Object.freeze([1, 0]);
|
||||
|
||||
export enum HeatmapSourceMode {
|
||||
Auto = 'auto',
|
||||
Calculate = 'calculate',
|
||||
Data = 'data', // Use the data as is
|
||||
}
|
||||
|
||||
export enum HeatmapColorMode {
|
||||
Opacity = 'opacity',
|
||||
Scheme = 'scheme',
|
||||
}
|
||||
|
||||
export enum HeatmapColorScale {
|
||||
Linear = 'linear',
|
||||
Exponential = 'exponential',
|
||||
}
|
||||
|
||||
export interface HeatmapColorOptions {
|
||||
mode: HeatmapColorMode;
|
||||
scheme: string; // when in scheme mode -- the d3 scheme name
|
||||
fill: string; // when opacity mode, the target color
|
||||
scale: HeatmapColorScale; // for opacity mode
|
||||
exponent: number; // when scale== sqrt
|
||||
steps: number; // 2-256
|
||||
|
||||
// Clamp the colors to the value range
|
||||
field?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface HeatmapTooltip {
|
||||
show: boolean;
|
||||
yHistogram?: boolean;
|
||||
}
|
||||
|
||||
export interface PanelOptions extends OptionsWithLegend {
|
||||
source: HeatmapSourceMode;
|
||||
|
||||
color: HeatmapColorOptions;
|
||||
heatmap?: HeatmapCalculationOptions;
|
||||
showValue: VisibilityMode;
|
||||
|
||||
cellGap?: number; // was cardPadding
|
||||
cellSize?: number; // was cardRadius
|
||||
|
||||
hideThreshold?: number; // was hideZeroBuckets
|
||||
yAxisLabels?: string;
|
||||
yAxisReverse?: boolean;
|
||||
|
||||
tooltip: HeatmapTooltip;
|
||||
}
|
||||
|
||||
export const defaultPanelOptions: PanelOptions = {
|
||||
source: HeatmapSourceMode.Auto,
|
||||
color: {
|
||||
mode: HeatmapColorMode.Scheme,
|
||||
scheme: 'Oranges',
|
||||
fill: 'dark-orange',
|
||||
scale: HeatmapColorScale.Exponential,
|
||||
exponent: 0.5,
|
||||
steps: 64,
|
||||
},
|
||||
showValue: VisibilityMode.Auto,
|
||||
legend: {
|
||||
displayMode: LegendDisplayMode.Hidden,
|
||||
placement: 'bottom',
|
||||
calcs: [],
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
yHistogram: false,
|
||||
},
|
||||
cellGap: 3,
|
||||
};
|
||||
|
||||
export interface PanelFieldConfig extends HideableFieldConfig {
|
||||
// TODO points vs lines etc
|
||||
}
|
||||
|
||||
export const defaultPanelFieldConfig: PanelFieldConfig = {
|
||||
// default to points?
|
||||
};
|
223
public/app/plugins/panel/heatmap-new/module.tsx
Normal file
223
public/app/plugins/panel/heatmap-new/module.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { GraphFieldConfig, VisibilityMode } from '@grafana/schema';
|
||||
import { Field, FieldType, PanelPlugin } from '@grafana/data';
|
||||
import { commonOptionsBuilder } from '@grafana/ui';
|
||||
import { HeatmapPanel } from './HeatmapPanel';
|
||||
import {
|
||||
PanelOptions,
|
||||
defaultPanelOptions,
|
||||
HeatmapSourceMode,
|
||||
HeatmapColorMode,
|
||||
HeatmapColorScale,
|
||||
} from './models.gen';
|
||||
import { HeatmapSuggestionsSupplier } from './suggestions';
|
||||
import { heatmapChangedHandler } from './migrations';
|
||||
import { addHeatmapCalculationOptions } from 'app/core/components/TransformersUI/calculateHeatmap/editor/helper';
|
||||
import { colorSchemes } from './palettes';
|
||||
|
||||
export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPanel)
|
||||
.useFieldConfig()
|
||||
.setPanelChangeHandler(heatmapChangedHandler)
|
||||
// .setMigrationHandler(heatmapMigrationHandler)
|
||||
.setPanelOptions((builder, context) => {
|
||||
const opts = context.options ?? defaultPanelOptions;
|
||||
|
||||
let category = ['Heatmap'];
|
||||
|
||||
builder.addRadio({
|
||||
path: 'source',
|
||||
name: 'Source',
|
||||
defaultValue: HeatmapSourceMode.Auto,
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ label: 'Auto', value: HeatmapSourceMode.Auto },
|
||||
{ label: 'Calculate', value: HeatmapSourceMode.Calculate },
|
||||
{ label: 'Raw data', description: 'The results are already heatmap buckets', value: HeatmapSourceMode.Data },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (opts.source === HeatmapSourceMode.Calculate) {
|
||||
addHeatmapCalculationOptions('heatmap.', builder, opts.heatmap, category);
|
||||
} else if (opts.source === HeatmapSourceMode.Data) {
|
||||
// builder.addSliderInput({
|
||||
// name: 'heatmap from the data...',
|
||||
// path: 'xxx',
|
||||
// });
|
||||
}
|
||||
|
||||
category = ['Colors'];
|
||||
|
||||
builder.addFieldNamePicker({
|
||||
path: `color.field`,
|
||||
name: 'Color with field',
|
||||
category,
|
||||
settings: {
|
||||
filter: (f: Field) => f.type === FieldType.number,
|
||||
noFieldsMessage: 'No numeric fields found',
|
||||
placeholderText: 'Auto',
|
||||
},
|
||||
});
|
||||
|
||||
builder.addRadio({
|
||||
path: `color.mode`,
|
||||
name: 'Mode',
|
||||
defaultValue: defaultPanelOptions.color.mode,
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ label: 'Scheme', value: HeatmapColorMode.Scheme },
|
||||
{ label: 'Opacity', value: HeatmapColorMode.Opacity },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
builder.addColorPicker({
|
||||
path: `color.fill`,
|
||||
name: 'Color',
|
||||
defaultValue: defaultPanelOptions.color.fill,
|
||||
category,
|
||||
showIf: (opts) => opts.color.mode === HeatmapColorMode.Opacity,
|
||||
});
|
||||
|
||||
builder.addRadio({
|
||||
path: `color.scale`,
|
||||
name: 'Scale',
|
||||
description: '',
|
||||
defaultValue: defaultPanelOptions.color.scale,
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ label: 'Exponential', value: HeatmapColorScale.Exponential },
|
||||
{ label: 'Linear', value: HeatmapColorScale.Linear },
|
||||
],
|
||||
},
|
||||
showIf: (opts) => opts.color.mode === HeatmapColorMode.Opacity,
|
||||
});
|
||||
|
||||
builder.addSliderInput({
|
||||
path: 'color.exponent',
|
||||
name: 'Exponent',
|
||||
defaultValue: defaultPanelOptions.color.exponent,
|
||||
category,
|
||||
settings: {
|
||||
min: 0.1, // 1 for on/off?
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
},
|
||||
showIf: (opts) =>
|
||||
opts.color.mode === HeatmapColorMode.Opacity && opts.color.scale === HeatmapColorScale.Exponential,
|
||||
});
|
||||
|
||||
builder.addSelect({
|
||||
path: `color.scheme`,
|
||||
name: 'Scheme',
|
||||
description: '',
|
||||
defaultValue: defaultPanelOptions.color.scheme,
|
||||
category,
|
||||
settings: {
|
||||
options: colorSchemes.map((scheme) => ({
|
||||
value: scheme.name,
|
||||
label: scheme.name,
|
||||
//description: 'Set a geometry field based on the results of other fields',
|
||||
})),
|
||||
},
|
||||
showIf: (opts) => opts.color.mode !== HeatmapColorMode.Opacity,
|
||||
});
|
||||
|
||||
builder.addSliderInput({
|
||||
path: 'color.steps',
|
||||
name: 'Max steps',
|
||||
defaultValue: defaultPanelOptions.color.steps,
|
||||
category,
|
||||
settings: {
|
||||
min: 2, // 1 for on/off?
|
||||
max: 128,
|
||||
step: 1,
|
||||
},
|
||||
});
|
||||
|
||||
category = ['Display'];
|
||||
|
||||
builder
|
||||
.addRadio({
|
||||
path: 'showValue',
|
||||
name: 'Show values',
|
||||
defaultValue: defaultPanelOptions.showValue,
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: VisibilityMode.Auto, label: 'Auto' },
|
||||
{ value: VisibilityMode.Always, label: 'Always' },
|
||||
{ value: VisibilityMode.Never, label: 'Never' },
|
||||
],
|
||||
},
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'hideThreshold',
|
||||
name: 'Hide cell counts <=',
|
||||
defaultValue: 0.000_000_001, // 1e-9
|
||||
category,
|
||||
})
|
||||
.addSliderInput({
|
||||
name: 'Cell gap',
|
||||
path: 'cellGap',
|
||||
defaultValue: defaultPanelOptions.cellGap,
|
||||
category,
|
||||
settings: {
|
||||
min: 0,
|
||||
max: 25,
|
||||
},
|
||||
})
|
||||
// .addSliderInput({
|
||||
// name: 'Cell radius',
|
||||
// path: 'cellRadius',
|
||||
// defaultValue: defaultPanelOptions.cellRadius,
|
||||
// category,
|
||||
// settings: {
|
||||
// min: 0,
|
||||
// max: 100,
|
||||
// },
|
||||
// })
|
||||
.addRadio({
|
||||
path: 'yAxisLabels',
|
||||
name: 'Axis labels',
|
||||
defaultValue: 'auto',
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'middle', label: 'Middle' },
|
||||
{ value: 'bottom', label: 'Bottom' },
|
||||
{ value: 'top', label: 'Top' },
|
||||
],
|
||||
},
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'yAxisReverse',
|
||||
name: 'Reverse buckets',
|
||||
defaultValue: defaultPanelOptions.yAxisReverse === true,
|
||||
category,
|
||||
});
|
||||
|
||||
category = ['Tooltip'];
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'tooltip.show',
|
||||
name: 'Show tooltip',
|
||||
defaultValue: defaultPanelOptions.tooltip.show,
|
||||
category,
|
||||
});
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'tooltip.yHistogram',
|
||||
name: 'Show histogram (Y axis)',
|
||||
defaultValue: defaultPanelOptions.tooltip.yHistogram,
|
||||
category,
|
||||
showIf: (opts) => opts.tooltip.show,
|
||||
});
|
||||
|
||||
// custom legend?
|
||||
commonOptionsBuilder.addLegendOptions(builder);
|
||||
})
|
||||
.setSuggestionsSupplier(new HeatmapSuggestionsSupplier());
|
107
public/app/plugins/panel/heatmap-new/palettes.ts
Normal file
107
public/app/plugins/panel/heatmap-new/palettes.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import * as d3 from 'd3';
|
||||
import * as d3ScaleChromatic from 'd3-scale-chromatic';
|
||||
import { HeatmapColorOptions, defaultPanelOptions, HeatmapColorMode, HeatmapColorScale } from './models.gen';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
// https://observablehq.com/@d3/color-schemes?collection=@d3/d3-scale-chromatic
|
||||
|
||||
// the previous heatmap panel used d3 deps and some code to interpolate to static 9-color palettes. here we just hard-code them for clarity.
|
||||
// if the need arises for configurable-sized palettes, we can bring back the deps & variable interpolation (see simplified code at end)
|
||||
|
||||
// Schemes from d3-scale-chromatic
|
||||
// https://github.com/d3/d3-scale-chromatic
|
||||
export const colorSchemes = [
|
||||
// Diverging
|
||||
{ name: 'BrBG', invert: 'always' },
|
||||
{ name: 'PiYG', invert: 'always' },
|
||||
{ name: 'PRGn', invert: 'always' },
|
||||
{ name: 'PuOr', invert: 'always' },
|
||||
{ name: 'RdBu', invert: 'always' },
|
||||
{ name: 'RdGy', invert: 'always' },
|
||||
{ name: 'RdYlBu', invert: 'always' },
|
||||
{ name: 'RdYlGn', invert: 'always' },
|
||||
{ name: 'Spectral', invert: 'always' },
|
||||
|
||||
// Sequential (Single Hue)
|
||||
{ name: 'Blues', invert: 'dark' },
|
||||
{ name: 'Greens', invert: 'dark' },
|
||||
{ name: 'Greys', invert: 'dark' },
|
||||
{ name: 'Oranges', invert: 'dark' },
|
||||
{ name: 'Purples', invert: 'dark' },
|
||||
{ name: 'Reds', invert: 'dark' },
|
||||
|
||||
// Sequential (Multi-Hue)
|
||||
{ name: 'Turbo', invert: 'light' },
|
||||
{ name: 'Cividis', invert: 'light' },
|
||||
{ name: 'Viridis', invert: 'light' },
|
||||
{ name: 'Magma', invert: 'light' },
|
||||
{ name: 'Inferno', invert: 'light' },
|
||||
{ name: 'Plasma', invert: 'light' },
|
||||
{ name: 'Warm', invert: 'light' },
|
||||
{ name: 'Cool', invert: 'light' },
|
||||
{ name: 'Cubehelix', invert: 'light', name2: 'CubehelixDefault' },
|
||||
{ name: 'BuGn', invert: 'dark' },
|
||||
{ name: 'BuPu', invert: 'dark' },
|
||||
{ name: 'GnBu', invert: 'dark' },
|
||||
{ name: 'OrRd', invert: 'dark' },
|
||||
{ name: 'PuBuGn', invert: 'dark' },
|
||||
{ name: 'PuBu', invert: 'dark' },
|
||||
{ name: 'PuRd', invert: 'dark' },
|
||||
{ name: 'RdPu', invert: 'dark' },
|
||||
{ name: 'YlGnBu', invert: 'dark' },
|
||||
{ name: 'YlGn', invert: 'dark' },
|
||||
{ name: 'YlOrBr', invert: 'dark' },
|
||||
{ name: 'YlOrRd', invert: 'dark' },
|
||||
|
||||
// Cyclical
|
||||
{ name: 'Rainbow', invert: 'always' },
|
||||
{ name: 'Sinebow', invert: 'always' },
|
||||
];
|
||||
|
||||
type Interpolator = (t: number) => string;
|
||||
|
||||
const DEFAULT_SCHEME = colorSchemes.find((scheme) => scheme.name === 'Spectral');
|
||||
|
||||
export function quantizeScheme(opts: HeatmapColorOptions, theme: GrafanaTheme2): string[] {
|
||||
const options = { ...defaultPanelOptions.color, ...opts };
|
||||
const palette = [];
|
||||
const steps = (options.steps ?? 128) - 1;
|
||||
|
||||
if (opts.mode === HeatmapColorMode.Opacity) {
|
||||
const fill = tinycolor(theme.visualization.getColorByName(opts.fill)).toPercentageRgb();
|
||||
|
||||
const scale =
|
||||
options.scale === HeatmapColorScale.Exponential
|
||||
? d3.scalePow().exponent(options.exponent).domain([0, 1]).range([0, 1])
|
||||
: d3.scaleLinear().domain([0, 1]).range([0, 1]);
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
fill.a = scale(i / steps);
|
||||
palette.push(tinycolor(fill).toString('hex8'));
|
||||
}
|
||||
} else {
|
||||
const scheme = colorSchemes.find((scheme) => scheme.name === options.scheme) ?? DEFAULT_SCHEME!;
|
||||
let fnName = 'interpolate' + (scheme.name2 ?? scheme.name);
|
||||
const interpolate: Interpolator = (d3ScaleChromatic as any)[fnName];
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
let rgbStr = interpolate(i / steps);
|
||||
let rgb =
|
||||
rgbStr.indexOf('rgb') === 0
|
||||
? '#' + [...rgbStr.matchAll(/\d+/g)].map((v) => (+v[0]).toString(16).padStart(2, '0')).join('')
|
||||
: rgbStr;
|
||||
palette.push(rgb);
|
||||
}
|
||||
|
||||
if (
|
||||
scheme.invert === 'always' ||
|
||||
(scheme.invert === 'dark' && theme.isDark) ||
|
||||
(scheme.invert === 'light' && theme.isLight)
|
||||
) {
|
||||
palette.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
return palette;
|
||||
}
|
18
public/app/plugins/panel/heatmap-new/plugin.json
Normal file
18
public/app/plugins/panel/heatmap-new/plugin.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"name": "Heatmap (preview)",
|
||||
"id": "heatmap-new",
|
||||
"state": "alpha",
|
||||
|
||||
"info": {
|
||||
"description": "Next generation heatmap visualization",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/heatmap.svg",
|
||||
"large": "img/heatmap.svg"
|
||||
}
|
||||
}
|
||||
}
|
37
public/app/plugins/panel/heatmap-new/suggestions.ts
Normal file
37
public/app/plugins/panel/heatmap-new/suggestions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { VisualizationSuggestionsBuilder } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { prepareHeatmapData } from './fields';
|
||||
import { PanelOptions, defaultPanelOptions } from './models.gen';
|
||||
|
||||
export class HeatmapSuggestionsSupplier {
|
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
||||
const { dataSummary } = builder;
|
||||
|
||||
if (
|
||||
!builder.data?.series ||
|
||||
!dataSummary.hasData ||
|
||||
dataSummary.timeFieldCount < 1 ||
|
||||
dataSummary.numberFieldCount < 2 ||
|
||||
dataSummary.numberFieldCount > 10
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const info = prepareHeatmapData(builder.data.series, defaultPanelOptions, config.theme2);
|
||||
if (!info || info.warning) {
|
||||
return;
|
||||
}
|
||||
|
||||
builder.getListAppender<PanelOptions, {}>({
|
||||
name: '',
|
||||
pluginId: 'heatmap-new',
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
custom: {},
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
395
public/app/plugins/panel/heatmap-new/utils.ts
Normal file
395
public/app/plugins/panel/heatmap-new/utils.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { MutableRefObject, RefObject } from 'react';
|
||||
import { GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema';
|
||||
import { UPlotConfigBuilder } from '@grafana/ui';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
|
||||
import { BucketLayout, HeatmapData } from './fields';
|
||||
|
||||
interface PathbuilderOpts {
|
||||
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
|
||||
gap?: number | null;
|
||||
hideThreshold?: number;
|
||||
xCeil?: boolean;
|
||||
yCeil?: boolean;
|
||||
disp: {
|
||||
fill: {
|
||||
values: (u: uPlot, seriesIndex: number) => number[];
|
||||
index: Array<CanvasRenderingContext2D['fillStyle']>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface HeatmapHoverEvent {
|
||||
index: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}
|
||||
|
||||
interface PrepConfigOpts {
|
||||
dataRef: RefObject<HeatmapData>;
|
||||
theme: GrafanaTheme2;
|
||||
onhover: (evt?: HeatmapHoverEvent | null) => void;
|
||||
onclick: (evt?: any) => void;
|
||||
isToolTipOpen: MutableRefObject<boolean>;
|
||||
timeZone: string;
|
||||
timeRange: TimeRange; // should be getTimeRange() cause dynamic?
|
||||
palette: string[];
|
||||
cellGap?: number | null; // in css pixels
|
||||
hideThreshold?: number;
|
||||
}
|
||||
|
||||
export function prepConfig(opts: PrepConfigOpts) {
|
||||
const { dataRef, theme, onhover, onclick, isToolTipOpen, timeZone, timeRange, palette, cellGap, hideThreshold } =
|
||||
opts;
|
||||
|
||||
let qt: Quadtree;
|
||||
let hRect: Rect | null;
|
||||
|
||||
let builder = new UPlotConfigBuilder(timeZone);
|
||||
|
||||
let rect: DOMRect;
|
||||
|
||||
builder.addHook('init', (u) => {
|
||||
u.root.querySelectorAll('.u-cursor-pt').forEach((el) => {
|
||||
Object.assign((el as HTMLElement).style, {
|
||||
borderRadius: '0',
|
||||
border: '1px solid white',
|
||||
background: 'transparent',
|
||||
});
|
||||
});
|
||||
u.over.addEventListener('click', onclick);
|
||||
});
|
||||
|
||||
// rect of .u-over (grid area)
|
||||
builder.addHook('syncRect', (u, r) => {
|
||||
rect = r;
|
||||
});
|
||||
|
||||
let pendingOnleave = 0;
|
||||
|
||||
builder.addHook('setLegend', (u) => {
|
||||
if (u.cursor.idxs != null) {
|
||||
for (let i = 0; i < u.cursor.idxs.length; i++) {
|
||||
const sel = u.cursor.idxs[i];
|
||||
if (sel != null && !isToolTipOpen.current) {
|
||||
if (pendingOnleave) {
|
||||
clearTimeout(pendingOnleave);
|
||||
pendingOnleave = 0;
|
||||
}
|
||||
|
||||
onhover({
|
||||
index: sel,
|
||||
pageX: rect.left + u.cursor.left!,
|
||||
pageY: rect.top + u.cursor.top!,
|
||||
});
|
||||
|
||||
return; // only show the first one
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isToolTipOpen.current) {
|
||||
// if tiles have gaps, reduce flashing / re-render (debounce onleave by 100ms)
|
||||
if (!pendingOnleave) {
|
||||
pendingOnleave = setTimeout(() => onhover(null), 100) as any;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
builder.addHook('drawClear', (u) => {
|
||||
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
|
||||
|
||||
qt.clear();
|
||||
|
||||
// force-clear the path cache to cause drawBars() to rebuild new quadtree
|
||||
u.series.forEach((s, i) => {
|
||||
if (i > 0) {
|
||||
// @ts-ignore
|
||||
s._paths = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
builder.setMode(2);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
isTime: true,
|
||||
orientation: ScaleOrientation.Horizontal,
|
||||
direction: ScaleDirection.Right,
|
||||
// TODO: expand by x bucket size and layout
|
||||
range: [timeRange.from.valueOf(), timeRange.to.valueOf()],
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
placement: AxisPlacement.Bottom,
|
||||
theme: theme,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
isTime: false,
|
||||
// distribution: ScaleDistribution.Ordinal, // does not work with facets/scatter yet
|
||||
orientation: ScaleOrientation.Vertical,
|
||||
direction: ScaleDirection.Up,
|
||||
range: (u, dataMin, dataMax) => {
|
||||
let bucketSize = dataRef.current?.yBucketSize;
|
||||
|
||||
if (dataRef.current?.yLayout === BucketLayout.le) {
|
||||
dataMin -= bucketSize!;
|
||||
} else {
|
||||
dataMax += bucketSize!;
|
||||
}
|
||||
|
||||
return [dataMin, dataMax];
|
||||
},
|
||||
});
|
||||
|
||||
const hasLabeledY = dataRef.current?.yAxisValues != null;
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
placement: AxisPlacement.Left,
|
||||
theme: theme,
|
||||
splits: hasLabeledY
|
||||
? () => {
|
||||
let ys = dataRef.current?.heatmap?.fields[1].values.toArray()!;
|
||||
let splits = ys.slice(0, ys.length - ys.lastIndexOf(ys[0]));
|
||||
|
||||
let bucketSize = dataRef.current?.yBucketSize!;
|
||||
|
||||
if (dataRef.current?.yLayout === BucketLayout.le) {
|
||||
splits.unshift(ys[0] - bucketSize);
|
||||
} else {
|
||||
splits.push(ys[ys.length - 1] + bucketSize);
|
||||
}
|
||||
|
||||
return splits;
|
||||
}
|
||||
: undefined,
|
||||
values: hasLabeledY
|
||||
? () => {
|
||||
let yAxisValues = dataRef.current?.yAxisValues?.slice()!;
|
||||
|
||||
if (dataRef.current?.yLayout === BucketLayout.le) {
|
||||
yAxisValues.unshift('0.0'); // assumes dense layout where lowest bucket's low bound is 0-ish
|
||||
} else {
|
||||
yAxisValues.push('+Inf');
|
||||
}
|
||||
|
||||
return yAxisValues;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
builder.addSeries({
|
||||
facets: [
|
||||
{
|
||||
scale: 'x',
|
||||
auto: true,
|
||||
sorted: 1,
|
||||
},
|
||||
{
|
||||
scale: 'y',
|
||||
auto: true,
|
||||
},
|
||||
],
|
||||
pathBuilder: heatmapPaths({
|
||||
each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => {
|
||||
qt.add({
|
||||
x: x - u.bbox.left,
|
||||
y: y - u.bbox.top,
|
||||
w: xSize,
|
||||
h: ySize,
|
||||
sidx: seriesIdx,
|
||||
didx: dataIdx,
|
||||
});
|
||||
},
|
||||
gap: cellGap,
|
||||
hideThreshold,
|
||||
xCeil: dataRef.current?.xLayout === BucketLayout.le,
|
||||
yCeil: dataRef.current?.yLayout === BucketLayout.le,
|
||||
disp: {
|
||||
fill: {
|
||||
values: (u, seriesIdx) => countsToFills(u, seriesIdx, palette),
|
||||
index: palette,
|
||||
},
|
||||
},
|
||||
}) as any,
|
||||
theme,
|
||||
scaleKey: '', // facets' scales used (above)
|
||||
});
|
||||
|
||||
builder.setCursor({
|
||||
dataIdx: (u, seriesIdx) => {
|
||||
if (seriesIdx === 1) {
|
||||
hRect = null;
|
||||
|
||||
let cx = u.cursor.left! * devicePixelRatio;
|
||||
let cy = u.cursor.top! * devicePixelRatio;
|
||||
|
||||
qt.get(cx, cy, 1, 1, (o) => {
|
||||
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
|
||||
hRect = o;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return hRect && seriesIdx === hRect.sidx ? hRect.didx : null;
|
||||
},
|
||||
points: {
|
||||
fill: 'rgba(255,255,255, 0.3)',
|
||||
bbox: (u, seriesIdx) => {
|
||||
let isHovered = hRect && seriesIdx === hRect.sidx;
|
||||
|
||||
return {
|
||||
left: isHovered ? hRect!.x / devicePixelRatio : -10,
|
||||
top: isHovered ? hRect!.y / devicePixelRatio : -10,
|
||||
width: isHovered ? hRect!.w / devicePixelRatio : 0,
|
||||
height: isHovered ? hRect!.h / devicePixelRatio : 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
export function heatmapPaths(opts: PathbuilderOpts) {
|
||||
const { disp, each, gap, hideThreshold = 0, xCeil = false, yCeil = false } = opts;
|
||||
|
||||
return (u: uPlot, seriesIdx: number) => {
|
||||
uPlot.orient(
|
||||
u,
|
||||
seriesIdx,
|
||||
(
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
moveTo,
|
||||
lineTo,
|
||||
rect,
|
||||
arc
|
||||
) => {
|
||||
let d = u.data[seriesIdx];
|
||||
const xs = d[0] as unknown as number[];
|
||||
const ys = d[1] as unknown as number[];
|
||||
const counts = d[2] as unknown as number[];
|
||||
const dlen = xs.length;
|
||||
|
||||
// fill colors are mapped from interpolating densities / counts along some gradient
|
||||
// (should be quantized to 64 colors/levels max. e.g. 16)
|
||||
let fills = disp.fill.values(u, seriesIdx);
|
||||
let fillPalette = disp.fill.index ?? [...new Set(fills)];
|
||||
|
||||
let fillPaths = fillPalette.map((color) => new Path2D());
|
||||
|
||||
// detect x and y bin qtys by detecting layout repetition in x & y data
|
||||
let yBinQty = dlen - ys.lastIndexOf(ys[0]);
|
||||
let xBinQty = dlen / yBinQty;
|
||||
let yBinIncr = ys[1] - ys[0];
|
||||
let xBinIncr = xs[yBinQty] - xs[0];
|
||||
|
||||
// uniform tile sizes based on zoom level
|
||||
let xSize = Math.abs(valToPosX(xBinIncr, scaleX, xDim, xOff) - valToPosX(0, scaleX, xDim, xOff));
|
||||
let ySize = Math.abs(valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff));
|
||||
|
||||
const autoGapFactor = 0.05;
|
||||
|
||||
// tile gap control
|
||||
let xGap = gap != null ? gap * devicePixelRatio : Math.max(0, autoGapFactor * Math.min(xSize, ySize));
|
||||
let yGap = xGap;
|
||||
|
||||
// clamp min tile size to 1px
|
||||
xSize = Math.max(1, Math.round(xSize - xGap));
|
||||
ySize = Math.max(1, Math.round(ySize - yGap));
|
||||
|
||||
// bucket agg direction
|
||||
// let xCeil = false;
|
||||
// let yCeil = false;
|
||||
|
||||
let xOffset = xCeil ? -xSize : 0;
|
||||
let yOffset = yCeil ? 0 : -ySize;
|
||||
|
||||
// pre-compute x and y offsets
|
||||
let cys = ys.slice(0, yBinQty).map((y) => Math.round(valToPosY(y, scaleY, yDim, yOff) + yOffset));
|
||||
let cxs = Array.from({ length: xBinQty }, (v, i) =>
|
||||
Math.round(valToPosX(xs[i * yBinQty], scaleX, xDim, xOff) + xOffset)
|
||||
);
|
||||
|
||||
for (let i = 0; i < dlen; i++) {
|
||||
// filter out 0 counts and out of view
|
||||
if (
|
||||
counts[i] > hideThreshold &&
|
||||
xs[i] + xBinIncr >= scaleX.min! &&
|
||||
xs[i] - xBinIncr <= scaleX.max! &&
|
||||
ys[i] + yBinIncr >= scaleY.min! &&
|
||||
ys[i] - yBinIncr <= scaleY.max!
|
||||
) {
|
||||
let cx = cxs[~~(i / yBinQty)];
|
||||
let cy = cys[i % yBinQty];
|
||||
|
||||
let fillPath = fillPaths[fills[i]];
|
||||
|
||||
rect(fillPath, cx, cy, xSize, ySize);
|
||||
|
||||
each(u, 1, i, cx, cy, xSize, ySize);
|
||||
}
|
||||
}
|
||||
|
||||
u.ctx.save();
|
||||
// u.ctx.globalAlpha = 0.8;
|
||||
u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
u.ctx.clip();
|
||||
fillPaths.forEach((p, i) => {
|
||||
u.ctx.fillStyle = fillPalette[i];
|
||||
u.ctx.fill(p);
|
||||
});
|
||||
u.ctx.restore();
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const countsToFills = (u: uPlot, seriesIdx: number, palette: string[]) => {
|
||||
let counts = u.data[seriesIdx][2] as unknown as number[];
|
||||
|
||||
// TODO: integrate 1e-9 hideThreshold?
|
||||
const hideThreshold = 0;
|
||||
|
||||
let minCount = Infinity;
|
||||
let maxCount = -Infinity;
|
||||
|
||||
for (let i = 0; i < counts.length; i++) {
|
||||
if (counts[i] > hideThreshold) {
|
||||
minCount = Math.min(minCount, counts[i]);
|
||||
maxCount = Math.max(maxCount, counts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
let range = maxCount - minCount;
|
||||
|
||||
let paletteSize = palette.length;
|
||||
|
||||
let indexedFills = Array(counts.length);
|
||||
|
||||
for (let i = 0; i < counts.length; i++) {
|
||||
indexedFills[i] =
|
||||
counts[i] === 0 ? -1 : Math.min(paletteSize - 1, Math.floor((paletteSize * (counts[i] - minCount)) / range));
|
||||
}
|
||||
|
||||
return indexedFills;
|
||||
};
|
Reference in New Issue
Block a user