mirror of https://github.com/grafana/grafana
Heatmap: new panel based based on uPlot (#44080)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>pull/45497/head
parent
685ec5383e
commit
bb86ba99ee
@ -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, |
||||
]; |
||||
@ -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> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
@ -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; |
||||
`,
|
||||
}); |
||||
@ -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/) |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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, |
||||
}; |
||||
}; |
||||
|
After Width: | Height: | Size: 3.4 KiB |
@ -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, |
||||
}; |
||||
@ -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; |
||||
}; |
||||
@ -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: [] |
||||
} |
||||
@ -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?
|
||||
}; |
||||
@ -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()); |
||||
@ -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; |
||||
} |
||||
@ -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" |
||||
} |
||||
} |
||||
} |
||||
@ -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: [], |
||||
}, |
||||
}); |
||||
} |
||||
} |
||||
@ -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; |
||||
}; |
||||
Loading…
Reference in new issue