From bb86ba99eeb61471f8e0ba374d63e43a9898b906 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 16 Feb 2022 14:49:17 -0800 Subject: [PATCH] Heatmap: new panel based based on uPlot (#44080) Co-authored-by: Leon Sorokin --- .../transformations/transformers/histogram.ts | 23 +- .../src/transformations/transformers/ids.ts | 1 + .../grafana-data/src/types/dataFrameTypes.ts | 13 + .../src/scuemata/dashboard/dist/family.cue | 2 + .../grafana-cli/commands/cuetsify_command.go | 1 + .../manager/manager_integration_test.go | 1 + .../HeatmapTransformerEditor.tsx | 49 +++ .../calculateHeatmap/editor/AxisEditor.tsx | 48 +++ .../calculateHeatmap/editor/helper.ts | 33 ++ .../calculateHeatmap/heatmap.test.ts | 23 + .../calculateHeatmap/heatmap.ts | 372 +++++++++++++++++ .../calculateHeatmap/models.gen.ts | 18 + .../TransformersUI/calculateHeatmap/utils.ts | 125 ++++++ .../TransformersUI/spatial/optionsHelper.tsx | 3 +- public/app/core/utils/standardTransformers.ts | 2 + .../app/features/plugins/built_in_plugins.ts | 2 + .../panel/heatmap-new/HeatmapHoverView.tsx | 194 +++++++++ .../panel/heatmap-new/HeatmapPanel.tsx | 125 ++++++ .../app/plugins/panel/heatmap-new/README.md | 7 + .../plugins/panel/heatmap-new/fields.test.ts | 13 + .../app/plugins/panel/heatmap-new/fields.ts | 115 +++++ .../plugins/panel/heatmap-new/img/heatmap.svg | 1 + .../panel/heatmap-new/migrations.test.ts | 135 ++++++ .../plugins/panel/heatmap-new/migrations.ts | 83 ++++ .../app/plugins/panel/heatmap-new/models.cue | 33 ++ .../plugins/panel/heatmap-new/models.gen.ts | 92 ++++ .../app/plugins/panel/heatmap-new/module.tsx | 223 ++++++++++ .../app/plugins/panel/heatmap-new/palettes.ts | 107 +++++ .../app/plugins/panel/heatmap-new/plugin.json | 18 + .../plugins/panel/heatmap-new/suggestions.ts | 37 ++ public/app/plugins/panel/heatmap-new/utils.ts | 395 ++++++++++++++++++ 31 files changed, 2285 insertions(+), 9 deletions(-) create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts create mode 100644 public/app/core/components/TransformersUI/calculateHeatmap/utils.ts create mode 100644 public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx create mode 100644 public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx create mode 100644 public/app/plugins/panel/heatmap-new/README.md create mode 100644 public/app/plugins/panel/heatmap-new/fields.test.ts create mode 100644 public/app/plugins/panel/heatmap-new/fields.ts create mode 100644 public/app/plugins/panel/heatmap-new/img/heatmap.svg create mode 100644 public/app/plugins/panel/heatmap-new/migrations.test.ts create mode 100644 public/app/plugins/panel/heatmap-new/migrations.ts create mode 100644 public/app/plugins/panel/heatmap-new/models.cue create mode 100644 public/app/plugins/panel/heatmap-new/models.gen.ts create mode 100644 public/app/plugins/panel/heatmap-new/module.tsx create mode 100644 public/app/plugins/panel/heatmap-new/palettes.ts create mode 100644 public/app/plugins/panel/heatmap-new/plugin.json create mode 100644 public/app/plugins/panel/heatmap-new/suggestions.ts create mode 100644 public/app/plugins/panel/heatmap-new/utils.ts diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts index 0e8b2c1da88..73a4a39c323 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.ts @@ -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; } diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index ed2066063e6..e8254b1949a 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -28,6 +28,7 @@ export enum DataTransformerID { prepareTimeSeries = 'prepareTimeSeries', convertFieldType = 'convertFieldType', fieldLookup = 'fieldLookup', + heatmap = 'heatmap', spatial = 'spatial', extractFields = 'extractFields', } diff --git a/packages/grafana-data/src/types/dataFrameTypes.ts b/packages/grafana-data/src/types/dataFrameTypes.ts index 9add0f27abf..f4faa9fda88 100644 --- a/packages/grafana-data/src/types/dataFrameTypes.ts +++ b/packages/grafana-data/src/types/dataFrameTypes.ts @@ -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', } diff --git a/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue b/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue index 4f97a4230d0..2f8f169ed7e 100644 --- a/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue +++ b/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue @@ -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 } } \ No newline at end of file diff --git a/pkg/cmd/grafana-cli/commands/cuetsify_command.go b/pkg/cmd/grafana-cli/commands/cuetsify_command.go index 73ae0d098c7..d71335f2431 100644 --- a/pkg/cmd/grafana-cli/commands/cuetsify_command.go +++ b/pkg/cmd/grafana-cli/commands/cuetsify_command.go @@ -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", diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index fb4f358f08c..e0c871f0428 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -119,6 +119,7 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) { "gettingstarted": {}, "graph": {}, "heatmap": {}, + "heatmap-new": {}, "histogram": {}, "icon": {}, "live": {}, diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx b/public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx new file mode 100644 index 00000000000..dc0e6eb1fed --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx @@ -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, + context: StandardEditorContext +) => { + const options = context.options ?? {}; + + addHeatmapCalculationOptions('', builder, options); +}; + +export const HeatmapTransformerEditor: React.FC> = (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(props, supplier); + return ( +
+
{pane.items.map((v) => v.render())}
+
+ ); +}; + +export const heatmapTransformRegistryItem: TransformerRegistryItem = { + id: heatmapTransformer.id, + editor: HeatmapTransformerEditor, + transformation: heatmapTransformer, + name: heatmapTransformer.name, + description: heatmapTransformer.description, + state: PluginState.alpha, +}; diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx b/public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx new file mode 100644 index 00000000000..a35e9ba126c --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx @@ -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> = [ + { + 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> = ({ + value, + onChange, + item, +}) => { + return ( + + { + onChange({ + ...value, + mode, + }); + }} + /> + { + onChange({ + ...value, + value: v.currentTarget.value, + }); + }} + /> + + ); +}; diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts b/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts new file mode 100644 index 00000000000..5ba5721f43d --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts @@ -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, + 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, + }, + }); +} diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts new file mode 100644 index 00000000000..58303f46cf5 --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts @@ -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(); + }); +}); diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts new file mode 100644 index 00000000000..1ca19f436ec --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts @@ -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 = { + 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(len); + const ys = new Array(len); + const counts = new Array(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]; +} diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts b/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts new file mode 100644 index 00000000000..eda086f883b --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts @@ -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; +} diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts b/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts new file mode 100644 index 00000000000..3851395f197 --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts @@ -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, +]; diff --git a/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx b/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx index 1d2ee131700..07ade5b3824 100644 --- a/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx +++ b/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx @@ -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( - props: TransformerUIProps, + props: TransformerUIProps, supplier: PanelOptionsSupplier ): OptionsPaneCategoryDescriptor { const context: StandardEditorContext = { diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts index a8815ae8adf..41f29b975d0 100644 --- a/public/app/core/utils/standardTransformers.ts +++ b/public/app/core/utils/standardTransformers.ts @@ -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> => { @@ -46,5 +47,6 @@ export const getStandardTransformers = (): Array> = spatialTransformRegistryItem, fieldLookupTransformRegistryItem, extractFieldsTransformRegistryItem, + heatmapTransformRegistryItem, ]; }; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index c542aa7dbb4..ebe372a6fff 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -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, diff --git a/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx new file mode 100644 index 00000000000..de0f990dd25 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx @@ -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> = []; + const linkLookup = new Set(); + + 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(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 ( + <> +
+
{xDisp(xBucketMin)}
+
{xDisp(xBucketMax)}
+
+ {showHistogram && ( + + )} +
+
+ Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)} +
+
Count: {count}
+
+ {links.length > 0 && ( + + {links.map((link, i) => ( + + {link.title} + + ))} + + )} + + ); +}; diff --git a/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx new file mode 100644 index 00000000000..7835e4d3d77 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx @@ -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 {} + +export const HeatmapPanel: React.FC = ({ + 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(undefined); + const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); + const isToolTipOpen = useRef(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(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 ; + } + + return ( + <> + + {(vizWidth: number, vizHeight: number) => ( + //
+          //   {JSON.stringify(scatterData, null, 2)}
+          // 
+ + {/*children ? children(config, alignedFrame) : null*/} + + )} +
+ + {hover && ( + + {shouldDisplayCloseButton && ( + <> + +
+ + )} + + + )} + + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + closeButtonSpacer: css` + margin-bottom: 15px; + `, +}); diff --git a/public/app/plugins/panel/heatmap-new/README.md b/public/app/plugins/panel/heatmap-new/README.md new file mode 100644 index 00000000000..626bf160c4f --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/README.md @@ -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/) diff --git a/public/app/plugins/panel/heatmap-new/fields.test.ts b/public/app/plugins/panel/heatmap-new/fields.test.ts new file mode 100644 index 00000000000..a8c5e7ee18a --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/fields.test.ts @@ -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(); + }); +}); diff --git a/public/app/plugins/panel/heatmap-new/fields.ts b/public/app/plugins/panel/heatmap-new/fields.ts new file mode 100644 index 00000000000..3a2e0418451 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/fields.ts @@ -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; + + 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, + }; +}; diff --git a/public/app/plugins/panel/heatmap-new/img/heatmap.svg b/public/app/plugins/panel/heatmap-new/img/heatmap.svg new file mode 100644 index 00000000000..25d98a0d5fd --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/img/heatmap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/heatmap-new/migrations.test.ts b/public/app/plugins/panel/heatmap-new/migrations.test.ts new file mode 100644 index 00000000000..acbfbbdfc9a --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/migrations.test.ts @@ -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, +}; diff --git a/public/app/plugins/panel/heatmap-new/migrations.ts b/public/app/plugins/panel/heatmap-new/migrations.ts new file mode 100644 index 00000000000..aee97869fb5 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/migrations.ts @@ -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 => { + // Nothing yet + return panel.options; +}; diff --git a/public/app/plugins/panel/heatmap-new/models.cue b/public/app/plugins/panel/heatmap-new/models.cue new file mode 100644 index 00000000000..1bb9205ed9a --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/models.cue @@ -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: [] +} diff --git a/public/app/plugins/panel/heatmap-new/models.gen.ts b/public/app/plugins/panel/heatmap-new/models.gen.ts new file mode 100644 index 00000000000..5124a5c73c9 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/models.gen.ts @@ -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? +}; diff --git a/public/app/plugins/panel/heatmap-new/module.tsx b/public/app/plugins/panel/heatmap-new/module.tsx new file mode 100644 index 00000000000..6ab1eef3663 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/module.tsx @@ -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(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()); diff --git a/public/app/plugins/panel/heatmap-new/palettes.ts b/public/app/plugins/panel/heatmap-new/palettes.ts new file mode 100644 index 00000000000..50fa0a54f9b --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/palettes.ts @@ -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; +} diff --git a/public/app/plugins/panel/heatmap-new/plugin.json b/public/app/plugins/panel/heatmap-new/plugin.json new file mode 100644 index 00000000000..4a320c089fb --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/plugin.json @@ -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" + } + } +} diff --git a/public/app/plugins/panel/heatmap-new/suggestions.ts b/public/app/plugins/panel/heatmap-new/suggestions.ts new file mode 100644 index 00000000000..508c88b0040 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/suggestions.ts @@ -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({ + name: '', + pluginId: 'heatmap-new', + options: {}, + fieldConfig: { + defaults: { + custom: {}, + }, + overrides: [], + }, + }); + } +} diff --git a/public/app/plugins/panel/heatmap-new/utils.ts b/public/app/plugins/panel/heatmap-new/utils.ts new file mode 100644 index 00000000000..caca5fc1dc0 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/utils.ts @@ -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; + }; + }; +} + +export interface HeatmapHoverEvent { + index: number; + pageX: number; + pageY: number; +} + +interface PrepConfigOpts { + dataRef: RefObject; + theme: GrafanaTheme2; + onhover: (evt?: HeatmapHoverEvent | null) => void; + onclick: (evt?: any) => void; + isToolTipOpen: MutableRefObject; + 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; +};