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 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 { LinkButton, VerticalGroup } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DataHoverView } from '../geomap/components/DataHoverView';
import { BucketLayout, HeatmapData } from './fields'; import { BucketLayout, HeatmapData } from './fields';
import { HeatmapHoverEvent } from './utils'; import { HeatmapHoverEvent } from './utils';
@ -177,6 +187,14 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => {
return <div>EXEMPLARS: {JSON.stringify(exemplarIndex)}</div>; 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 ( return (
<> <>
<div> <div>

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import { MutableRefObject, RefObject } from 'react'; import { MutableRefObject, RefObject } from 'react';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { DataFrameType, GrafanaTheme2, TimeRange } from '@grafana/data';
import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema'; import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '@grafana/schema';
import { UPlotConfigBuilder } from '@grafana/ui'; import { UPlotConfigBuilder } from '@grafana/ui';
import { pointWithin, Quadtree, Rect } from '../barchart/quadtree'; import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
@ -63,6 +63,10 @@ export function prepConfig(opts: PrepConfigOpts) {
hideThreshold, hideThreshold,
} = opts; } = opts;
const pxRatio = devicePixelRatio;
let heatmapType = dataRef.current?.heatmap?.meta?.type;
let qt: Quadtree; let qt: Quadtree;
let hRect: Rect | null; let hRect: Rect | null;
@ -191,20 +195,31 @@ export function prepConfig(opts: PrepConfigOpts) {
theme: theme, theme: theme,
}); });
const shouldUseLogScale = heatmapType === DataFrameType.HeatmapSparse;
builder.addScale({ builder.addScale({
scaleKey: 'y', scaleKey: 'y',
isTime: false, isTime: false,
// distribution: ScaleDistribution.Ordinal, // does not work with facets/scatter yet // distribution: ScaleDistribution.Ordinal, // does not work with facets/scatter yet
orientation: ScaleOrientation.Vertical, orientation: ScaleOrientation.Vertical,
direction: ScaleDirection.Up, direction: ScaleDirection.Up,
range: (u, dataMin, dataMax) => { // should be tweakable manually
distribution: shouldUseLogScale ? ScaleDistribution.Log : ScaleDistribution.Linear,
log: 2,
range: shouldUseLogScale
? undefined
: (u, dataMin, dataMax) => {
let bucketSize = dataRef.current?.yBucketSize; let bucketSize = dataRef.current?.yBucketSize;
if (bucketSize) {
if (dataRef.current?.yLayout === BucketLayout.le) { if (dataRef.current?.yLayout === BucketLayout.le) {
dataMin -= bucketSize!; dataMin -= bucketSize!;
} else { } else {
dataMax += bucketSize!; dataMax += bucketSize!;
} }
} else {
// how to expand scale range if inferred non-regular or log buckets?
}
return [dataMin, dataMax]; return [dataMin, dataMax];
}, },
@ -247,6 +262,8 @@ export function prepConfig(opts: PrepConfigOpts) {
: undefined, : undefined,
}); });
let pathBuilder = heatmapType === DataFrameType.HeatmapScanlines ? heatmapPathsDense : heatmapPathsSparse;
builder.addSeries({ builder.addSeries({
facets: [ facets: [
{ {
@ -259,7 +276,7 @@ export function prepConfig(opts: PrepConfigOpts) {
auto: true, auto: true,
}, },
], ],
pathBuilder: heatmapPaths({ pathBuilder: pathBuilder({
each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => { each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => {
qt.add({ qt.add({
x: x - u.bbox.left, x: x - u.bbox.left,
@ -276,7 +293,10 @@ export function prepConfig(opts: PrepConfigOpts) {
yCeil: dataRef.current?.yLayout === BucketLayout.le, yCeil: dataRef.current?.yLayout === BucketLayout.le,
disp: { disp: {
fill: { 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, index: palette,
}, },
}, },
@ -295,8 +315,8 @@ export function prepConfig(opts: PrepConfigOpts) {
if (seriesIdx === 1) { if (seriesIdx === 1) {
hRect = null; hRect = null;
let cx = u.cursor.left! * devicePixelRatio; let cx = u.cursor.left! * pxRatio;
let cy = u.cursor.top! * devicePixelRatio; let cy = u.cursor.top! * pxRatio;
qt.get(cx, cy, 1, 1, (o) => { qt.get(cx, cy, 1, 1, (o) => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { 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; let isHovered = hRect && seriesIdx === hRect.sidx;
return { return {
left: isHovered ? hRect!.x / devicePixelRatio : -10, left: isHovered ? hRect!.x / pxRatio : -10,
top: isHovered ? hRect!.y / devicePixelRatio : -10, top: isHovered ? hRect!.y / pxRatio : -10,
width: isHovered ? hRect!.w / devicePixelRatio : 0, width: isHovered ? hRect!.w / pxRatio : 0,
height: isHovered ? hRect!.h / devicePixelRatio : 0, height: isHovered ? hRect!.h / pxRatio : 0,
}; };
}, },
}, },
@ -325,8 +345,16 @@ export function prepConfig(opts: PrepConfigOpts) {
return builder; return builder;
} }
export function heatmapPaths(opts: PathbuilderOpts) { const CRISP_EDGES_GAP_MIN = 4;
const { disp, each, gap, hideThreshold = 0, xCeil = false, yCeil = false } = opts;
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) => { return (u: uPlot, seriesIdx: number) => {
uPlot.orient( 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 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)); 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 // clamp min tile size to 1px
xSize = Math.max(1, Math.round(xSize - xGap)); xSize = Math.max(1, round(xSize - cellGap));
ySize = Math.max(1, Math.round(ySize - yGap)); ySize = Math.max(1, round(ySize - cellGap));
// bucket agg direction // bucket agg direction
// let xCeil = false; // let xCeil = false;
@ -390,9 +412,9 @@ export function heatmapPaths(opts: PathbuilderOpts) {
let yOffset = yCeil ? 0 : -ySize; let yOffset = yCeil ? 0 : -ySize;
// pre-compute x and y offsets // 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) => 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++) { 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[]) => { // accepts xMax, yMin, yMax, count
let counts = u.data[seriesIdx][2] as unknown as number[]; // 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? // TODO: integrate 1e-9 hideThreshold?
const hideThreshold = 0; const hideThreshold = 0;