HeatmapNG: Sparse renderer (#48993)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Leon Sorokin 2022-05-15 23:03:50 -05:00 committed by GitHub
parent 97759c75f4
commit c1b56e79ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 218 additions and 48 deletions

View File

@ -1,9 +1,19 @@
import React, { useEffect, useRef } from 'react';
import { DataFrameView, Field, FieldType, formattedValueToString, getFieldDisplayName, LinkModel } from '@grafana/data';
import {
DataFrameType,
DataFrameView,
Field,
FieldType,
formattedValueToString,
getFieldDisplayName,
LinkModel,
} from '@grafana/data';
import { LinkButton, VerticalGroup } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DataHoverView } from '../geomap/components/DataHoverView';
import { BucketLayout, HeatmapData } from './fields';
import { HeatmapHoverEvent } from './utils';
@ -177,6 +187,14 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
return <div>EXEMPLARS: {JSON.stringify(exemplarIndex)}</div>;
};
if (data.heatmap?.meta?.type === DataFrameType.HeatmapSparse) {
return (
<div>
<DataHoverView data={data.heatmap} rowIndex={hover.index} />
</div>
);
}
return (
<>
<div>

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { GrafanaTheme2, PanelProps, reduceField, ReducerID, TimeRange } from '@grafana/data';
import { DataFrameType, GrafanaTheme2, PanelProps, reduceField, ReducerID, TimeRange } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import {
Portal,
@ -16,7 +16,7 @@ import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
import { HeatmapHoverView } from './HeatmapHoverView';
import { HeatmapData, prepareHeatmapData } from './fields';
import { prepareHeatmapData } from './fields';
import { PanelOptions } from './models.gen';
import { quantizeScheme } from './palettes';
import { HeatmapHoverEvent, prepConfig } from './utils';
@ -76,7 +76,7 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
);
// ugh
const dataRef = useRef<HeatmapData>(info);
const dataRef = useRef(info);
dataRef.current = info;
const builder = useMemo(() => {
@ -103,13 +103,15 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
return null;
}
const field = info.heatmap.fields[2];
const { min, max } = reduceField({ field, reducers: [ReducerID.min, ReducerID.max] });
let heatmapType = dataRef.current?.heatmap?.meta?.type;
let countFieldIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3;
const countField = info.heatmap.fields[countFieldIdx];
const { min, max } = reduceField({ field: countField, reducers: [ReducerID.min, ReducerID.max] });
let hoverValue: number | undefined = undefined;
if (hover && info.heatmap.fields) {
const countField = info.heatmap.fields[2];
hoverValue = countField?.values.get(hover.index);
hoverValue = countField.values.get(hover.index);
}
return (

View File

@ -68,6 +68,11 @@ export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme
return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), exemplars, theme);
}
let sparseCellsHeatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapSparse);
if (sparseCellsHeatmap) {
return getSparseHeatmapData(sparseCellsHeatmap, exemplars, theme);
}
// Find a well defined heatmap
let scanlinesHeatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapScanlines);
if (scanlinesHeatmap) {
@ -84,12 +89,8 @@ export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme
};
}
let first = frames[0];
if (first.meta?.type === DataFrameType.HeatmapSparse) {
return getSparseHeatmapData(first, exemplars, theme);
}
if (source === HeatmapSourceMode.Data) {
let first = frames[0];
if (first.meta?.type !== DataFrameType.HeatmapScanlines) {
first = bucketsToScanlines(frames[0]);
}

View File

@ -30,7 +30,7 @@ export interface HeatmapColorOptions {
fill: string; // when opacity mode, the target color
scale: HeatmapColorScale; // for opacity mode
exponent: number; // when scale== sqrt
steps: number; // 2-256
steps: number; // 2-128
// Clamp the colors to the value range
field?: string;
@ -80,7 +80,7 @@ export const defaultPanelOptions: PanelOptions = {
show: true,
yHistogram: false,
},
cellGap: 3,
cellGap: 1,
};
export interface PanelFieldConfig extends HideableFieldConfig {

View File

@ -1,8 +1,8 @@
import { MutableRefObject, RefObject } from 'react';
import uPlot from 'uplot';
import { GrafanaTheme2, TimeRange } from '@grafana/data';
import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema';
import { DataFrameType, GrafanaTheme2, TimeRange } from '@grafana/data';
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '@grafana/schema';
import { UPlotConfigBuilder } from '@grafana/ui';
import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
@ -63,6 +63,10 @@ export function prepConfig(opts: PrepConfigOpts) {
hideThreshold,
} = opts;
const pxRatio = devicePixelRatio;
let heatmapType = dataRef.current?.heatmap?.meta?.type;
let qt: Quadtree;
let hRect: Rect | null;
@ -191,23 +195,34 @@ export function prepConfig(opts: PrepConfigOpts) {
theme: theme,
});
const shouldUseLogScale = heatmapType === DataFrameType.HeatmapSparse;
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;
// should be tweakable manually
distribution: shouldUseLogScale ? ScaleDistribution.Log : ScaleDistribution.Linear,
log: 2,
range: shouldUseLogScale
? undefined
: (u, dataMin, dataMax) => {
let bucketSize = dataRef.current?.yBucketSize;
if (dataRef.current?.yLayout === BucketLayout.le) {
dataMin -= bucketSize!;
} else {
dataMax += bucketSize!;
}
if (bucketSize) {
if (dataRef.current?.yLayout === BucketLayout.le) {
dataMin -= bucketSize!;
} else {
dataMax += bucketSize!;
}
} else {
// how to expand scale range if inferred non-regular or log buckets?
}
return [dataMin, dataMax];
},
return [dataMin, dataMax];
},
});
const hasLabeledY = dataRef.current?.yAxisValues != null;
@ -247,6 +262,8 @@ export function prepConfig(opts: PrepConfigOpts) {
: undefined,
});
let pathBuilder = heatmapType === DataFrameType.HeatmapScanlines ? heatmapPathsDense : heatmapPathsSparse;
builder.addSeries({
facets: [
{
@ -259,7 +276,7 @@ export function prepConfig(opts: PrepConfigOpts) {
auto: true,
},
],
pathBuilder: heatmapPaths({
pathBuilder: pathBuilder({
each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => {
qt.add({
x: x - u.bbox.left,
@ -276,7 +293,10 @@ export function prepConfig(opts: PrepConfigOpts) {
yCeil: dataRef.current?.yLayout === BucketLayout.le,
disp: {
fill: {
values: (u, seriesIdx) => countsToFills(u, seriesIdx, palette),
values: (u, seriesIdx) => {
let countFacetIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3;
return countsToFills(u.data[seriesIdx][countFacetIdx] as unknown as number[], palette);
},
index: palette,
},
},
@ -295,8 +315,8 @@ export function prepConfig(opts: PrepConfigOpts) {
if (seriesIdx === 1) {
hRect = null;
let cx = u.cursor.left! * devicePixelRatio;
let cy = u.cursor.top! * devicePixelRatio;
let cx = u.cursor.left! * pxRatio;
let cy = u.cursor.top! * pxRatio;
qt.get(cx, cy, 1, 1, (o) => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
@ -313,10 +333,10 @@ export function prepConfig(opts: PrepConfigOpts) {
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,
left: isHovered ? hRect!.x / pxRatio : -10,
top: isHovered ? hRect!.y / pxRatio : -10,
width: isHovered ? hRect!.w / pxRatio : 0,
height: isHovered ? hRect!.h / pxRatio : 0,
};
},
},
@ -325,8 +345,16 @@ export function prepConfig(opts: PrepConfigOpts) {
return builder;
}
export function heatmapPaths(opts: PathbuilderOpts) {
const { disp, each, gap, hideThreshold = 0, xCeil = false, yCeil = false } = opts;
const CRISP_EDGES_GAP_MIN = 4;
export function heatmapPathsDense(opts: PathbuilderOpts) {
const { disp, each, gap = 1, hideThreshold = 0, xCeil = false, yCeil = false } = opts;
const pxRatio = devicePixelRatio;
const round = gap! >= CRISP_EDGES_GAP_MIN ? Math.round : (v: number) => v;
const cellGap = Math.round(gap! * pxRatio);
return (u: uPlot, seriesIdx: number) => {
uPlot.orient(
@ -372,15 +400,9 @@ export function heatmapPaths(opts: PathbuilderOpts) {
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));
xSize = Math.max(1, round(xSize - cellGap));
ySize = Math.max(1, round(ySize - cellGap));
// bucket agg direction
// let xCeil = false;
@ -390,9 +412,9 @@ export function heatmapPaths(opts: PathbuilderOpts) {
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 cys = ys.slice(0, yBinQty).map((y) => 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)
round(valToPosX(xs[i * yBinQty], scaleX, xDim, xOff) + xOffset)
);
for (let i = 0; i < dlen; i++) {
@ -431,9 +453,136 @@ export function heatmapPaths(opts: PathbuilderOpts) {
};
}
export const countsToFills = (u: uPlot, seriesIdx: number, palette: string[]) => {
let counts = u.data[seriesIdx][2] as unknown as number[];
// accepts xMax, yMin, yMax, count
// xbinsize? x tile sizes are uniform?
export function heatmapPathsSparse(opts: PathbuilderOpts) {
const { disp, each, gap = 1, hideThreshold = 0 } = opts;
const pxRatio = devicePixelRatio;
const round = gap! >= CRISP_EDGES_GAP_MIN ? Math.round : (v: number) => v;
const cellGap = Math.round(gap! * pxRatio);
return (u: uPlot, seriesIdx: number) => {
uPlot.orient(
u,
seriesIdx,
(
series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
moveTo,
lineTo,
rect,
arc
) => {
//console.time('heatmapPathsSparse');
let d = u.data[seriesIdx];
const xMaxs = d[0] as unknown as number[]; // xMax, do we get interval?
const yMins = d[1] as unknown as number[];
const yMaxs = d[2] as unknown as number[];
const counts = d[3] as unknown as number[];
const dlen = xMaxs.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());
// cache all tile bounds
let xOffs = new Map();
let yOffs = new Map();
for (let i = 0; i < xMaxs.length; i++) {
let xMax = xMaxs[i];
let yMin = yMins[i];
let yMax = yMaxs[i];
if (!xOffs.has(xMax)) {
xOffs.set(xMax, round(valToPosX(xMax, scaleX, xDim, xOff)));
}
if (!yOffs.has(yMin)) {
yOffs.set(yMin, round(valToPosY(yMin, scaleY, yDim, yOff)));
}
if (!yOffs.has(yMax)) {
yOffs.set(yMax, round(valToPosY(yMax, scaleY, yDim, yOff)));
}
}
// uniform x size (interval, step)
let xSizeUniform = xOffs.get(xMaxs.find((v) => v !== xMaxs[0])) - xOffs.get(xMaxs[0]);
for (let i = 0; i < dlen; i++) {
if (counts[i] <= hideThreshold) {
continue;
}
let xMax = xMaxs[i];
let yMin = yMins[i];
let yMax = yMaxs[i];
let xMaxPx = xOffs.get(xMax); // xSize is from interval, or inferred delta?
let yMinPx = yOffs.get(yMin);
let yMaxPx = yOffs.get(yMax);
let xSize = xSizeUniform;
let ySize = yMinPx - yMaxPx;
// clamp min tile size to 1px
xSize = Math.max(1, xSize - cellGap);
ySize = Math.max(1, ySize - cellGap);
let x = xMaxPx;
let y = yMinPx;
// filter out 0 counts and out of view
// if (
// xs[i] + xBinIncr >= scaleX.min! &&
// xs[i] - xBinIncr <= scaleX.max! &&
// ys[i] + yBinIncr >= scaleY.min! &&
// ys[i] - yBinIncr <= scaleY.max!
// ) {
let fillPath = fillPaths[fills[i]];
rect(fillPath, x, y, xSize, ySize);
each(u, 1, i, x, y, 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();
//console.timeEnd('heatmapPathsSparse');
return null;
}
);
};
}
export const countsToFills = (counts: number[], palette: string[]) => {
// TODO: integrate 1e-9 hideThreshold?
const hideThreshold = 0;