Heatmap: new panel based based on uPlot (#44080)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Ryan McKinley
2022-02-16 14:49:17 -08:00
committed by GitHub
parent 685ec5383e
commit bb86ba99ee
31 changed files with 2285 additions and 9 deletions

View File

@@ -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;
}

View File

@@ -28,6 +28,7 @@ export enum DataTransformerID {
prepareTimeSeries = 'prepareTimeSeries',
convertFieldType = 'convertFieldType',
fieldLookup = 'fieldLookup',
heatmap = 'heatmap',
spatial = 'spatial',
extractFields = 'extractFields',
}

View File

@@ -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',
}

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -119,6 +119,7 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
"gettingstarted": {},
"graph": {},
"heatmap": {},
"heatmap-new": {},
"histogram": {},
"icon": {},
"live": {},

View File

@@ -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,
};

View File

@@ -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>
);
};

View File

@@ -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,
},
});
}

View File

@@ -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();
});
});

View File

@@ -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];
}

View File

@@ -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;
}

View File

@@ -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,
];

View File

@@ -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> = {

View File

@@ -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,
];
};

View File

@@ -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,

View 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>
)}
</>
);
};

View 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;
`,
});

View 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/)

View 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();
});
});

View 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,
};
};

View 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

View 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,
};

View 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;
};

View 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: []
}

View 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?
};

View 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());

View 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;
}

View 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"
}
}
}

View 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: [],
},
});
}
}

View 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;
};